taskledger 0.1.0__py3-none-any.whl
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.
- taskledger/__init__.py +5 -0
- taskledger/__main__.py +6 -0
- taskledger/_version.py +24 -0
- taskledger/api/__init__.py +13 -0
- taskledger/api/handoff.py +247 -0
- taskledger/api/introductions.py +9 -0
- taskledger/api/locks.py +4 -0
- taskledger/api/plans.py +31 -0
- taskledger/api/project.py +185 -0
- taskledger/api/questions.py +19 -0
- taskledger/api/search.py +87 -0
- taskledger/api/task_runs.py +38 -0
- taskledger/api/tasks.py +61 -0
- taskledger/cli.py +600 -0
- taskledger/cli_actor.py +196 -0
- taskledger/cli_common.py +617 -0
- taskledger/cli_implement.py +409 -0
- taskledger/cli_migrate.py +328 -0
- taskledger/cli_misc.py +984 -0
- taskledger/cli_plan.py +478 -0
- taskledger/cli_question.py +350 -0
- taskledger/cli_task.py +257 -0
- taskledger/cli_validate.py +285 -0
- taskledger/command_inventory.py +125 -0
- taskledger/domain/__init__.py +2 -0
- taskledger/domain/models.py +1697 -0
- taskledger/domain/policies.py +542 -0
- taskledger/domain/states.py +320 -0
- taskledger/errors.py +165 -0
- taskledger/exchange.py +343 -0
- taskledger/ids.py +19 -0
- taskledger/py.typed +0 -0
- taskledger/search.py +349 -0
- taskledger/services/__init__.py +1 -0
- taskledger/services/actors.py +245 -0
- taskledger/services/dashboard.py +306 -0
- taskledger/services/doctor.py +435 -0
- taskledger/services/handoff.py +1029 -0
- taskledger/services/handoff_lifecycle.py +154 -0
- taskledger/services/navigation.py +930 -0
- taskledger/services/phase5_lock_transfer.py +96 -0
- taskledger/services/plan_lint.py +397 -0
- taskledger/services/serve_read_model.py +852 -0
- taskledger/services/tasks.py +4224 -0
- taskledger/services/validation.py +221 -0
- taskledger/services/web_dashboard.py +1742 -0
- taskledger/storage/__init__.py +39 -0
- taskledger/storage/atomic.py +57 -0
- taskledger/storage/common.py +90 -0
- taskledger/storage/events.py +98 -0
- taskledger/storage/frontmatter.py +57 -0
- taskledger/storage/indexes.py +42 -0
- taskledger/storage/init.py +187 -0
- taskledger/storage/locks.py +83 -0
- taskledger/storage/meta.py +103 -0
- taskledger/storage/migrations.py +207 -0
- taskledger/storage/paths.py +166 -0
- taskledger/storage/project_config.py +393 -0
- taskledger/storage/repos.py +256 -0
- taskledger/storage/task_store.py +836 -0
- taskledger/timeutils.py +7 -0
- taskledger-0.1.0.dist-info/METADATA +411 -0
- taskledger-0.1.0.dist-info/RECORD +67 -0
- taskledger-0.1.0.dist-info/WHEEL +5 -0
- taskledger-0.1.0.dist-info/entry_points.txt +2 -0
- taskledger-0.1.0.dist-info/licenses/LICENSE +201 -0
- taskledger-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1742 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
# ruff: noqa: E501
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import socket
|
|
7
|
+
import webbrowser
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from textwrap import dedent
|
|
13
|
+
from urllib.parse import parse_qs, urlparse
|
|
14
|
+
|
|
15
|
+
from taskledger.errors import LaunchError
|
|
16
|
+
from taskledger.services.serve_read_model import (
|
|
17
|
+
serve_dashboard_snapshot,
|
|
18
|
+
serve_project_summary,
|
|
19
|
+
serve_task_events,
|
|
20
|
+
serve_task_summaries,
|
|
21
|
+
)
|
|
22
|
+
from taskledger.storage.task_store import (
|
|
23
|
+
resolve_task_or_active,
|
|
24
|
+
resolve_v2_paths,
|
|
25
|
+
task_dir,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True, frozen=True)
|
|
30
|
+
class DashboardServerConfig:
|
|
31
|
+
workspace_root: Path
|
|
32
|
+
host: str = "127.0.0.1"
|
|
33
|
+
port: int = 8765
|
|
34
|
+
task_ref: str | None = None
|
|
35
|
+
refresh_ms: int = 1000
|
|
36
|
+
open_browser: bool = False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(slots=True)
|
|
40
|
+
class CachedResponse:
|
|
41
|
+
revision: str
|
|
42
|
+
body: bytes
|
|
43
|
+
content_type: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _DashboardHTTPServer(ThreadingHTTPServer):
|
|
47
|
+
daemon_threads = True
|
|
48
|
+
allow_reuse_address = True
|
|
49
|
+
workspace_root: Path
|
|
50
|
+
default_task_ref: str | None
|
|
51
|
+
refresh_ms: int
|
|
52
|
+
cache: dict[str, CachedResponse]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(slots=True)
|
|
56
|
+
class DashboardServerHandle:
|
|
57
|
+
server: _DashboardHTTPServer
|
|
58
|
+
host: str
|
|
59
|
+
port: int
|
|
60
|
+
url: str
|
|
61
|
+
_serving: bool = False
|
|
62
|
+
|
|
63
|
+
def serve_forever(self) -> None:
|
|
64
|
+
self._serving = True
|
|
65
|
+
try:
|
|
66
|
+
self.server.serve_forever()
|
|
67
|
+
finally:
|
|
68
|
+
self._serving = False
|
|
69
|
+
|
|
70
|
+
def close(self) -> None:
|
|
71
|
+
if self._serving:
|
|
72
|
+
self.server.shutdown()
|
|
73
|
+
self.server.server_close()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def render_index_html(*, refresh_ms: int, task_ref: str | None) -> str:
|
|
77
|
+
return "\n".join(
|
|
78
|
+
[
|
|
79
|
+
"<!doctype html>",
|
|
80
|
+
'<html lang="en">',
|
|
81
|
+
"<head>",
|
|
82
|
+
' <meta charset="utf-8">',
|
|
83
|
+
' <meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
84
|
+
" <title>Taskledger dashboard</title>",
|
|
85
|
+
_render_dashboard_css(),
|
|
86
|
+
"</head>",
|
|
87
|
+
_render_dashboard_body(),
|
|
88
|
+
_render_dashboard_script(refresh_ms, task_ref),
|
|
89
|
+
"</html>",
|
|
90
|
+
]
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _render_dashboard_css() -> str:
|
|
95
|
+
return dedent(
|
|
96
|
+
"""\
|
|
97
|
+
<style>
|
|
98
|
+
:root {
|
|
99
|
+
color-scheme: light dark;
|
|
100
|
+
--bg: #f6f7f9;
|
|
101
|
+
--panel: #ffffff;
|
|
102
|
+
--panel-muted: #f7f9fc;
|
|
103
|
+
--panel-strong: #e8eef8;
|
|
104
|
+
--text: #0f172a;
|
|
105
|
+
--muted: #64748b;
|
|
106
|
+
--border: #dde4ee;
|
|
107
|
+
--accent: #2563eb;
|
|
108
|
+
--accent-soft: rgba(37, 99, 235, 0.12);
|
|
109
|
+
--success: #15803d;
|
|
110
|
+
--warning: #b45309;
|
|
111
|
+
--danger: #b91c1c;
|
|
112
|
+
--radius: 14px;
|
|
113
|
+
--shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
@media (prefers-color-scheme: dark) {
|
|
117
|
+
:root {
|
|
118
|
+
--bg: #0b1020;
|
|
119
|
+
--panel: #111827;
|
|
120
|
+
--panel-muted: #182235;
|
|
121
|
+
--panel-strong: #1f2d44;
|
|
122
|
+
--text: #e5e7eb;
|
|
123
|
+
--muted: #9ca3af;
|
|
124
|
+
--border: #263244;
|
|
125
|
+
--accent-soft: rgba(96, 165, 250, 0.18);
|
|
126
|
+
--shadow: none;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
* { box-sizing: border-box; }
|
|
131
|
+
html, body { min-height: 100%; }
|
|
132
|
+
body {
|
|
133
|
+
margin: 0;
|
|
134
|
+
background: var(--bg);
|
|
135
|
+
color: var(--text);
|
|
136
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
137
|
+
line-height: 1.5;
|
|
138
|
+
}
|
|
139
|
+
button, input {
|
|
140
|
+
font: inherit;
|
|
141
|
+
color: inherit;
|
|
142
|
+
}
|
|
143
|
+
code, pre, .mono {
|
|
144
|
+
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
|
|
145
|
+
}
|
|
146
|
+
pre {
|
|
147
|
+
margin: 0;
|
|
148
|
+
white-space: pre-wrap;
|
|
149
|
+
overflow-wrap: anywhere;
|
|
150
|
+
}
|
|
151
|
+
button {
|
|
152
|
+
border: none;
|
|
153
|
+
background: none;
|
|
154
|
+
padding: 0;
|
|
155
|
+
}
|
|
156
|
+
button:focus-visible,
|
|
157
|
+
input:focus-visible,
|
|
158
|
+
summary:focus-visible {
|
|
159
|
+
outline: 2px solid var(--accent);
|
|
160
|
+
outline-offset: 2px;
|
|
161
|
+
}
|
|
162
|
+
.app-header {
|
|
163
|
+
position: sticky;
|
|
164
|
+
top: 0;
|
|
165
|
+
z-index: 10;
|
|
166
|
+
display: flex;
|
|
167
|
+
justify-content: space-between;
|
|
168
|
+
gap: 1rem;
|
|
169
|
+
align-items: flex-start;
|
|
170
|
+
padding: 1rem 1.25rem;
|
|
171
|
+
border-bottom: 1px solid var(--border);
|
|
172
|
+
background: color-mix(in srgb, var(--bg) 92%, transparent);
|
|
173
|
+
backdrop-filter: blur(8px);
|
|
174
|
+
}
|
|
175
|
+
.eyebrow {
|
|
176
|
+
margin: 0 0 0.35rem 0;
|
|
177
|
+
color: var(--muted);
|
|
178
|
+
font-size: 0.8rem;
|
|
179
|
+
letter-spacing: 0.08em;
|
|
180
|
+
text-transform: uppercase;
|
|
181
|
+
}
|
|
182
|
+
.app-header h1 {
|
|
183
|
+
margin: 0;
|
|
184
|
+
font-size: 1.6rem;
|
|
185
|
+
}
|
|
186
|
+
.app-subtitle,
|
|
187
|
+
.status-detail {
|
|
188
|
+
margin: 0.35rem 0 0 0;
|
|
189
|
+
color: var(--muted);
|
|
190
|
+
max-width: 52rem;
|
|
191
|
+
}
|
|
192
|
+
.header-tools,
|
|
193
|
+
.header-meta {
|
|
194
|
+
display: grid;
|
|
195
|
+
gap: 0.75rem;
|
|
196
|
+
}
|
|
197
|
+
.header-tools {
|
|
198
|
+
min-width: min(100%, 28rem);
|
|
199
|
+
}
|
|
200
|
+
.meta-card,
|
|
201
|
+
.card {
|
|
202
|
+
border: 1px solid var(--border);
|
|
203
|
+
border-radius: var(--radius);
|
|
204
|
+
background: var(--panel);
|
|
205
|
+
box-shadow: var(--shadow);
|
|
206
|
+
}
|
|
207
|
+
.meta-card {
|
|
208
|
+
padding: 0.75rem 0.9rem;
|
|
209
|
+
}
|
|
210
|
+
.meta-card strong {
|
|
211
|
+
display: block;
|
|
212
|
+
margin-top: 0.15rem;
|
|
213
|
+
}
|
|
214
|
+
.meta-label {
|
|
215
|
+
display: block;
|
|
216
|
+
color: var(--muted);
|
|
217
|
+
font-size: 0.8rem;
|
|
218
|
+
margin-bottom: 0.25rem;
|
|
219
|
+
}
|
|
220
|
+
.header-controls {
|
|
221
|
+
display: flex;
|
|
222
|
+
gap: 0.65rem;
|
|
223
|
+
flex-wrap: wrap;
|
|
224
|
+
}
|
|
225
|
+
.action-button,
|
|
226
|
+
.copy-button {
|
|
227
|
+
border-radius: 10px;
|
|
228
|
+
border: 1px solid var(--border);
|
|
229
|
+
background: var(--panel);
|
|
230
|
+
padding: 0.55rem 0.8rem;
|
|
231
|
+
cursor: pointer;
|
|
232
|
+
}
|
|
233
|
+
.action-button-primary {
|
|
234
|
+
background: var(--accent);
|
|
235
|
+
border-color: var(--accent);
|
|
236
|
+
color: white;
|
|
237
|
+
}
|
|
238
|
+
.status-live {
|
|
239
|
+
color: var(--success);
|
|
240
|
+
}
|
|
241
|
+
.status-paused {
|
|
242
|
+
color: var(--warning);
|
|
243
|
+
}
|
|
244
|
+
.status-refreshing {
|
|
245
|
+
color: var(--accent);
|
|
246
|
+
}
|
|
247
|
+
.dashboard-layout {
|
|
248
|
+
display: grid;
|
|
249
|
+
grid-template-columns: minmax(18rem, 22rem) minmax(0, 1fr) minmax(18rem, 22rem);
|
|
250
|
+
gap: 1.1rem;
|
|
251
|
+
padding: 1rem 1.25rem 1.5rem;
|
|
252
|
+
align-items: start;
|
|
253
|
+
}
|
|
254
|
+
.sidebar-sticky,
|
|
255
|
+
.rail-sticky {
|
|
256
|
+
position: sticky;
|
|
257
|
+
top: 6.75rem;
|
|
258
|
+
display: grid;
|
|
259
|
+
gap: 1rem;
|
|
260
|
+
}
|
|
261
|
+
.sidebar-sticky {
|
|
262
|
+
max-height: calc(100vh - 7.75rem);
|
|
263
|
+
}
|
|
264
|
+
.main-column,
|
|
265
|
+
.hero-grid,
|
|
266
|
+
.metric-grid,
|
|
267
|
+
.section-stack,
|
|
268
|
+
.rail-sticky {
|
|
269
|
+
display: grid;
|
|
270
|
+
gap: 1rem;
|
|
271
|
+
}
|
|
272
|
+
.main-column {
|
|
273
|
+
min-width: 0;
|
|
274
|
+
}
|
|
275
|
+
.metric-grid {
|
|
276
|
+
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
|
|
277
|
+
}
|
|
278
|
+
.card {
|
|
279
|
+
padding: 1.05rem;
|
|
280
|
+
min-width: 0;
|
|
281
|
+
}
|
|
282
|
+
.muted-card {
|
|
283
|
+
background: var(--panel-muted);
|
|
284
|
+
}
|
|
285
|
+
.card-header {
|
|
286
|
+
display: flex;
|
|
287
|
+
justify-content: space-between;
|
|
288
|
+
gap: 0.75rem;
|
|
289
|
+
align-items: flex-start;
|
|
290
|
+
margin-bottom: 0.9rem;
|
|
291
|
+
}
|
|
292
|
+
.card-header h2,
|
|
293
|
+
.card-header h3 {
|
|
294
|
+
margin: 0;
|
|
295
|
+
font-size: 1rem;
|
|
296
|
+
}
|
|
297
|
+
.hero-card {
|
|
298
|
+
padding: 1.15rem;
|
|
299
|
+
}
|
|
300
|
+
.next-action-card {
|
|
301
|
+
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
|
|
302
|
+
box-shadow: 0 14px 30px rgba(37, 99, 235, 0.12);
|
|
303
|
+
}
|
|
304
|
+
.hero-title {
|
|
305
|
+
display: flex;
|
|
306
|
+
flex-wrap: wrap;
|
|
307
|
+
gap: 0.5rem;
|
|
308
|
+
align-items: center;
|
|
309
|
+
margin-bottom: 0.6rem;
|
|
310
|
+
}
|
|
311
|
+
.hero-title h2 {
|
|
312
|
+
margin: 0;
|
|
313
|
+
font-size: 1.35rem;
|
|
314
|
+
}
|
|
315
|
+
.hero-meta,
|
|
316
|
+
.mini-meta,
|
|
317
|
+
.metric-value,
|
|
318
|
+
.list-grid,
|
|
319
|
+
.timeline {
|
|
320
|
+
display: grid;
|
|
321
|
+
gap: 0.65rem;
|
|
322
|
+
}
|
|
323
|
+
.hero-meta {
|
|
324
|
+
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
|
|
325
|
+
}
|
|
326
|
+
.meta-row {
|
|
327
|
+
padding: 0.75rem;
|
|
328
|
+
border-radius: 12px;
|
|
329
|
+
border: 1px solid var(--border);
|
|
330
|
+
background: var(--panel-muted);
|
|
331
|
+
}
|
|
332
|
+
.meta-row strong,
|
|
333
|
+
.metric-number {
|
|
334
|
+
display: block;
|
|
335
|
+
font-size: 1.1rem;
|
|
336
|
+
margin-top: 0.2rem;
|
|
337
|
+
}
|
|
338
|
+
.muted {
|
|
339
|
+
color: var(--muted);
|
|
340
|
+
}
|
|
341
|
+
.badge-row,
|
|
342
|
+
.filter-row,
|
|
343
|
+
.pill-row {
|
|
344
|
+
display: flex;
|
|
345
|
+
flex-wrap: wrap;
|
|
346
|
+
gap: 0.5rem;
|
|
347
|
+
}
|
|
348
|
+
.badge,
|
|
349
|
+
.chip {
|
|
350
|
+
display: inline-flex;
|
|
351
|
+
align-items: center;
|
|
352
|
+
gap: 0.35rem;
|
|
353
|
+
border-radius: 999px;
|
|
354
|
+
font-size: 0.8rem;
|
|
355
|
+
line-height: 1;
|
|
356
|
+
padding: 0.38rem 0.65rem;
|
|
357
|
+
border: 1px solid var(--border);
|
|
358
|
+
background: color-mix(in srgb, var(--panel-muted) 75%, transparent);
|
|
359
|
+
color: var(--text);
|
|
360
|
+
}
|
|
361
|
+
.chip {
|
|
362
|
+
cursor: pointer;
|
|
363
|
+
}
|
|
364
|
+
.chip[data-active="true"],
|
|
365
|
+
.task-card[data-active="true"] {
|
|
366
|
+
border-color: var(--accent);
|
|
367
|
+
background: var(--accent-soft);
|
|
368
|
+
}
|
|
369
|
+
.badge-success { color: var(--success); }
|
|
370
|
+
.badge-warning { color: var(--warning); }
|
|
371
|
+
.badge-danger { color: var(--danger); }
|
|
372
|
+
.badge-info { color: var(--accent); }
|
|
373
|
+
.badge-muted { color: var(--muted); }
|
|
374
|
+
.search-stack {
|
|
375
|
+
display: grid;
|
|
376
|
+
gap: 0.65rem;
|
|
377
|
+
}
|
|
378
|
+
.search-stack label {
|
|
379
|
+
font-size: 0.85rem;
|
|
380
|
+
color: var(--muted);
|
|
381
|
+
}
|
|
382
|
+
.task-search {
|
|
383
|
+
width: 100%;
|
|
384
|
+
border: 1px solid var(--border);
|
|
385
|
+
border-radius: 12px;
|
|
386
|
+
background: var(--panel);
|
|
387
|
+
padding: 0.8rem 0.9rem;
|
|
388
|
+
}
|
|
389
|
+
.task-list {
|
|
390
|
+
display: grid;
|
|
391
|
+
gap: 0.7rem;
|
|
392
|
+
overflow: auto;
|
|
393
|
+
padding-right: 0.15rem;
|
|
394
|
+
}
|
|
395
|
+
.task-card {
|
|
396
|
+
width: 100%;
|
|
397
|
+
text-align: left;
|
|
398
|
+
border: 1px solid var(--border);
|
|
399
|
+
border-radius: 14px;
|
|
400
|
+
background: var(--panel);
|
|
401
|
+
box-shadow: var(--shadow);
|
|
402
|
+
padding: 0.9rem;
|
|
403
|
+
cursor: pointer;
|
|
404
|
+
}
|
|
405
|
+
.task-card:hover {
|
|
406
|
+
border-color: var(--accent);
|
|
407
|
+
}
|
|
408
|
+
.task-title {
|
|
409
|
+
margin: 0.4rem 0 0.3rem 0;
|
|
410
|
+
font-size: 0.96rem;
|
|
411
|
+
font-weight: 650;
|
|
412
|
+
}
|
|
413
|
+
.meta-line {
|
|
414
|
+
color: var(--muted);
|
|
415
|
+
font-size: 0.82rem;
|
|
416
|
+
}
|
|
417
|
+
.summary-line {
|
|
418
|
+
margin-top: 0.55rem;
|
|
419
|
+
color: var(--muted);
|
|
420
|
+
font-size: 0.85rem;
|
|
421
|
+
}
|
|
422
|
+
.empty-state {
|
|
423
|
+
border: 1px dashed var(--border);
|
|
424
|
+
border-radius: 14px;
|
|
425
|
+
padding: 0.9rem;
|
|
426
|
+
color: var(--muted);
|
|
427
|
+
background: var(--panel-muted);
|
|
428
|
+
}
|
|
429
|
+
.command-row {
|
|
430
|
+
display: flex;
|
|
431
|
+
gap: 0.65rem;
|
|
432
|
+
align-items: center;
|
|
433
|
+
flex-wrap: wrap;
|
|
434
|
+
border: 1px solid var(--border);
|
|
435
|
+
border-radius: 12px;
|
|
436
|
+
background: var(--panel-muted);
|
|
437
|
+
padding: 0.7rem 0.8rem;
|
|
438
|
+
}
|
|
439
|
+
.progress-block {
|
|
440
|
+
display: grid;
|
|
441
|
+
gap: 0.4rem;
|
|
442
|
+
}
|
|
443
|
+
.progress-track {
|
|
444
|
+
position: relative;
|
|
445
|
+
height: 0.7rem;
|
|
446
|
+
border-radius: 999px;
|
|
447
|
+
border: 1px solid var(--border);
|
|
448
|
+
background: var(--panel-strong);
|
|
449
|
+
overflow: hidden;
|
|
450
|
+
}
|
|
451
|
+
.progress-fill {
|
|
452
|
+
position: absolute;
|
|
453
|
+
inset: 0 auto 0 0;
|
|
454
|
+
height: 100%;
|
|
455
|
+
background: linear-gradient(90deg, var(--accent), #60a5fa);
|
|
456
|
+
border-radius: inherit;
|
|
457
|
+
}
|
|
458
|
+
.criteria-grid,
|
|
459
|
+
.change-grid {
|
|
460
|
+
display: grid;
|
|
461
|
+
gap: 0.75rem;
|
|
462
|
+
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
|
463
|
+
}
|
|
464
|
+
.item-card,
|
|
465
|
+
.timeline-item {
|
|
466
|
+
border: 1px solid var(--border);
|
|
467
|
+
border-radius: 12px;
|
|
468
|
+
background: var(--panel-muted);
|
|
469
|
+
padding: 0.85rem;
|
|
470
|
+
}
|
|
471
|
+
.todo-next {
|
|
472
|
+
border-color: var(--accent);
|
|
473
|
+
background: var(--accent-soft);
|
|
474
|
+
}
|
|
475
|
+
.item-title {
|
|
476
|
+
display: flex;
|
|
477
|
+
justify-content: space-between;
|
|
478
|
+
gap: 0.65rem;
|
|
479
|
+
align-items: flex-start;
|
|
480
|
+
margin-bottom: 0.45rem;
|
|
481
|
+
}
|
|
482
|
+
.item-title strong {
|
|
483
|
+
display: block;
|
|
484
|
+
}
|
|
485
|
+
ul.clean-list {
|
|
486
|
+
margin: 0;
|
|
487
|
+
padding-left: 1.1rem;
|
|
488
|
+
display: grid;
|
|
489
|
+
gap: 0.35rem;
|
|
490
|
+
}
|
|
491
|
+
details {
|
|
492
|
+
border-top: 1px solid var(--border);
|
|
493
|
+
margin-top: 0.75rem;
|
|
494
|
+
padding-top: 0.75rem;
|
|
495
|
+
}
|
|
496
|
+
.section-details {
|
|
497
|
+
border-top: none;
|
|
498
|
+
margin-top: 0;
|
|
499
|
+
padding-top: 0;
|
|
500
|
+
}
|
|
501
|
+
.section-details summary {
|
|
502
|
+
font-weight: 600;
|
|
503
|
+
}
|
|
504
|
+
.collapsible-card .section-subtitle:first-of-type {
|
|
505
|
+
margin-top: 0.7rem;
|
|
506
|
+
}
|
|
507
|
+
summary {
|
|
508
|
+
cursor: pointer;
|
|
509
|
+
color: var(--muted);
|
|
510
|
+
}
|
|
511
|
+
.section-subtitle {
|
|
512
|
+
margin: -0.3rem 0 0.8rem 0;
|
|
513
|
+
color: var(--muted);
|
|
514
|
+
}
|
|
515
|
+
.stack-gap {
|
|
516
|
+
display: grid;
|
|
517
|
+
gap: 0.9rem;
|
|
518
|
+
}
|
|
519
|
+
.debug-json {
|
|
520
|
+
max-height: 20rem;
|
|
521
|
+
overflow: auto;
|
|
522
|
+
}
|
|
523
|
+
.raw-payload details + details {
|
|
524
|
+
margin-top: 0.75rem;
|
|
525
|
+
}
|
|
526
|
+
@media (max-width: 1200px) {
|
|
527
|
+
.dashboard-layout {
|
|
528
|
+
grid-template-columns: minmax(18rem, 22rem) minmax(0, 1fr);
|
|
529
|
+
}
|
|
530
|
+
.right-rail {
|
|
531
|
+
grid-column: 1 / -1;
|
|
532
|
+
}
|
|
533
|
+
.rail-sticky {
|
|
534
|
+
position: static;
|
|
535
|
+
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
@media (max-width: 900px) {
|
|
539
|
+
.app-header {
|
|
540
|
+
position: static;
|
|
541
|
+
flex-direction: column;
|
|
542
|
+
}
|
|
543
|
+
.header-tools,
|
|
544
|
+
.header-meta {
|
|
545
|
+
min-width: 0;
|
|
546
|
+
width: 100%;
|
|
547
|
+
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
|
548
|
+
}
|
|
549
|
+
.dashboard-layout {
|
|
550
|
+
grid-template-columns: 1fr;
|
|
551
|
+
}
|
|
552
|
+
.sidebar-sticky,
|
|
553
|
+
.rail-sticky {
|
|
554
|
+
position: static;
|
|
555
|
+
max-height: none;
|
|
556
|
+
}
|
|
557
|
+
.task-list {
|
|
558
|
+
max-height: none;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
@media (prefers-reduced-motion: reduce) {
|
|
562
|
+
* {
|
|
563
|
+
scroll-behavior: auto;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
</style>
|
|
567
|
+
"""
|
|
568
|
+
).strip()
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _render_dashboard_body() -> str:
|
|
572
|
+
return dedent(
|
|
573
|
+
"""\
|
|
574
|
+
<body>
|
|
575
|
+
<header class="app-header">
|
|
576
|
+
<div>
|
|
577
|
+
<p class="eyebrow">Taskledger</p>
|
|
578
|
+
<h1>Taskledger dashboard</h1>
|
|
579
|
+
<p id="status-headline" class="app-subtitle">Waiting for first refresh.</p>
|
|
580
|
+
<p id="status-detail" class="status-detail">The dashboard will load the selected task automatically.</p>
|
|
581
|
+
</div>
|
|
582
|
+
<div class="header-tools">
|
|
583
|
+
<div class="header-meta">
|
|
584
|
+
<div class="meta-card">
|
|
585
|
+
<span class="meta-label">Selected task</span>
|
|
586
|
+
<strong id="selected-task-label">active</strong>
|
|
587
|
+
</div>
|
|
588
|
+
<div class="meta-card">
|
|
589
|
+
<span class="meta-label">Last refresh</span>
|
|
590
|
+
<strong id="last-updated-label">never</strong>
|
|
591
|
+
</div>
|
|
592
|
+
<div class="meta-card">
|
|
593
|
+
<span class="meta-label">Review status</span>
|
|
594
|
+
<strong id="live-status-label" class="status-live">Live</strong>
|
|
595
|
+
</div>
|
|
596
|
+
</div>
|
|
597
|
+
<div class="header-controls">
|
|
598
|
+
<button id="toggle-polling-button" class="action-button" type="button">Pause updates</button>
|
|
599
|
+
<button id="refresh-now-button" class="action-button action-button-primary" type="button">Refresh now</button>
|
|
600
|
+
</div>
|
|
601
|
+
</div>
|
|
602
|
+
</header>
|
|
603
|
+
<div class="dashboard-layout">
|
|
604
|
+
<aside aria-label="Tasks">
|
|
605
|
+
<div class="sidebar-sticky">
|
|
606
|
+
<section class="card muted-card">
|
|
607
|
+
<div class="search-stack">
|
|
608
|
+
<label for="task-search">Search tasks</label>
|
|
609
|
+
<input
|
|
610
|
+
id="task-search"
|
|
611
|
+
class="task-search"
|
|
612
|
+
type="search"
|
|
613
|
+
placeholder="Filter by title, task id, or slug"
|
|
614
|
+
>
|
|
615
|
+
<nav id="task-filters" class="filter-row" aria-label="Task filters"></nav>
|
|
616
|
+
</div>
|
|
617
|
+
</section>
|
|
618
|
+
<div id="tasks" class="task-list"></div>
|
|
619
|
+
</div>
|
|
620
|
+
</aside>
|
|
621
|
+
<main class="main-column">
|
|
622
|
+
<div id="hero-slot" class="hero-grid"></div>
|
|
623
|
+
<section id="metric-grid" class="metric-grid" aria-label="Progress overview"></section>
|
|
624
|
+
<div id="sections" class="section-stack"></div>
|
|
625
|
+
</main>
|
|
626
|
+
<aside class="right-rail" aria-label="Current work">
|
|
627
|
+
<div id="rail-content" class="rail-sticky"></div>
|
|
628
|
+
</aside>
|
|
629
|
+
</div>
|
|
630
|
+
"""
|
|
631
|
+
).strip()
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _render_dashboard_script(refresh_ms: int, task_ref: str | None) -> str:
|
|
635
|
+
script = dedent(
|
|
636
|
+
"""\
|
|
637
|
+
<script>
|
|
638
|
+
const refreshMs = __REFRESH_MS__;
|
|
639
|
+
const defaultTaskRef = __DEFAULT_TASK_REF__;
|
|
640
|
+
let selectedTaskRef = defaultTaskRef ?? "active";
|
|
641
|
+
let refreshTimer = null;
|
|
642
|
+
let refreshInFlight = false;
|
|
643
|
+
let pollingPaused = false;
|
|
644
|
+
let lastUpdatedText = "never";
|
|
645
|
+
let taskSearchQuery = "";
|
|
646
|
+
let taskStageFilter = "all";
|
|
647
|
+
const openDetailsKeys = new Set();
|
|
648
|
+
|
|
649
|
+
const STAGE_FILTERS = [
|
|
650
|
+
{ value: "all", label: "All" },
|
|
651
|
+
{ value: "active", label: "Active" },
|
|
652
|
+
{ value: "draft", label: "Draft" },
|
|
653
|
+
{ value: "review", label: "Review" },
|
|
654
|
+
{ value: "approved", label: "Approved" },
|
|
655
|
+
{ value: "implementation", label: "Implementing" },
|
|
656
|
+
{ value: "validation", label: "Validating" },
|
|
657
|
+
{ value: "failed", label: "Failed" },
|
|
658
|
+
{ value: "done", label: "Done" },
|
|
659
|
+
{ value: "cancelled", label: "Cancelled" },
|
|
660
|
+
];
|
|
661
|
+
|
|
662
|
+
const endpointState = {
|
|
663
|
+
tasks: { key: null, etag: null, payload: null, error: null, lastRequestedAt: 0 },
|
|
664
|
+
project: { key: null, etag: null, payload: null, error: null, lastRequestedAt: 0 },
|
|
665
|
+
dashboard: { key: null, etag: null, payload: null, error: null, lastRequestedAt: 0 },
|
|
666
|
+
events: { key: null, etag: null, payload: null, error: null, lastRequestedAt: 0 },
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
function apiTaskRef() {
|
|
670
|
+
return selectedTaskRef === "active" ? "active" : selectedTaskRef;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function endpointPath(name) {
|
|
674
|
+
if (name === "tasks") return "/api/tasks";
|
|
675
|
+
if (name === "project") return "/api/project";
|
|
676
|
+
const taskRef = encodeURIComponent(apiTaskRef());
|
|
677
|
+
if (name === "dashboard") return "/api/dashboard?task=" + taskRef;
|
|
678
|
+
return "/api/events?task=" + taskRef + "&limit=50";
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function endpointCadence(name) {
|
|
682
|
+
if (name === "tasks") return Math.max(refreshMs * 5, 5000);
|
|
683
|
+
if (name === "project") return Math.max(refreshMs * 15, 15000);
|
|
684
|
+
return refreshMs;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
async function getJson(name, path) {
|
|
688
|
+
const state = endpointState[name];
|
|
689
|
+
if (state.key !== path) {
|
|
690
|
+
state.key = path;
|
|
691
|
+
state.etag = null;
|
|
692
|
+
state.payload = null;
|
|
693
|
+
state.error = null;
|
|
694
|
+
}
|
|
695
|
+
const headers = {};
|
|
696
|
+
if (state.etag) {
|
|
697
|
+
headers["If-None-Match"] = state.etag;
|
|
698
|
+
}
|
|
699
|
+
const response = await fetch(path, { headers });
|
|
700
|
+
if (response.status === 304 && state.payload) {
|
|
701
|
+
return { payload: state.payload, changed: false };
|
|
702
|
+
}
|
|
703
|
+
const payload = await response.json();
|
|
704
|
+
if (!response.ok) {
|
|
705
|
+
throw new Error(payload?.error?.message || ("HTTP " + response.status));
|
|
706
|
+
}
|
|
707
|
+
const previousPayload = state.payload;
|
|
708
|
+
const previousRevision = previousPayload?.revision;
|
|
709
|
+
state.etag = response.headers.get("ETag");
|
|
710
|
+
state.payload = payload;
|
|
711
|
+
return {
|
|
712
|
+
payload,
|
|
713
|
+
changed: previousPayload === null || payload?.revision !== previousRevision,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function h(tag, attrs = {}, children = []) {
|
|
718
|
+
const node = document.createElement(tag);
|
|
719
|
+
for (const [key, value] of Object.entries(attrs || {})) {
|
|
720
|
+
if (value === null || value === undefined || value === false) continue;
|
|
721
|
+
if (key === "class") node.className = String(value);
|
|
722
|
+
else if (key === "text") node.textContent = String(value);
|
|
723
|
+
else if (key === "style") node.setAttribute("style", String(value));
|
|
724
|
+
else if (key === "htmlFor") node.htmlFor = String(value);
|
|
725
|
+
else if (key === "value") node.value = String(value);
|
|
726
|
+
else if (key.startsWith("aria-") || key.startsWith("data-") || key === "role" || key === "type" || key === "placeholder" || key === "id") {
|
|
727
|
+
node.setAttribute(key, String(value));
|
|
728
|
+
} else {
|
|
729
|
+
node[key] = value;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
const list = (Array.isArray(children) ? children : [children]).flat(Infinity);
|
|
733
|
+
for (const child of list) {
|
|
734
|
+
if (child === null || child === undefined) continue;
|
|
735
|
+
node.append(child && child.nodeType ? child : document.createTextNode(String(child)));
|
|
736
|
+
}
|
|
737
|
+
return node;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function clearNode(node) {
|
|
741
|
+
if (node) node.replaceChildren();
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function emptyState(text) {
|
|
745
|
+
return h("div", { class: "empty-state", text });
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function rememberDetailsState(root = document) {
|
|
749
|
+
for (const node of root.querySelectorAll("details[data-detail-key]")) {
|
|
750
|
+
const key = node.getAttribute("data-detail-key");
|
|
751
|
+
if (!key) continue;
|
|
752
|
+
if (node.open) openDetailsKeys.add(key);
|
|
753
|
+
else openDetailsKeys.delete(key);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function bindDetailsState(details, key, openByDefault = false) {
|
|
758
|
+
details.setAttribute("data-detail-key", key);
|
|
759
|
+
details.open = openDetailsKeys.has(key) ? true : openByDefault;
|
|
760
|
+
details.addEventListener("toggle", () => {
|
|
761
|
+
if (details.open) openDetailsKeys.add(key);
|
|
762
|
+
else openDetailsKeys.delete(key);
|
|
763
|
+
});
|
|
764
|
+
return details;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function replaceContentPreservingDetails(container, children) {
|
|
768
|
+
if (!container) return;
|
|
769
|
+
rememberDetailsState(container);
|
|
770
|
+
container.replaceChildren(...children.filter(Boolean));
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function titleCase(value) {
|
|
774
|
+
return String(value || "").replace(/[_-]+/g, " ").replace(/\\b\\w/g, (letter) => letter.toUpperCase());
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function formatTimestamp(value) {
|
|
778
|
+
if (!value) return "Unknown";
|
|
779
|
+
const parsed = new Date(value);
|
|
780
|
+
if (Number.isNaN(parsed.getTime())) return String(value);
|
|
781
|
+
return parsed.toLocaleString();
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function toneForStage(task) {
|
|
785
|
+
const stage = task?.status_stage;
|
|
786
|
+
const activeStage = task?.active_stage;
|
|
787
|
+
if (activeStage === "validation" || stage === "plan_review") return "warning";
|
|
788
|
+
if (stage === "failed_validation") return "danger";
|
|
789
|
+
if (stage === "done") return "success";
|
|
790
|
+
if (stage === "cancelled") return "muted";
|
|
791
|
+
if (activeStage || stage === "approved" || stage === "implemented") return "info";
|
|
792
|
+
return "muted";
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function toneForStatus(status) {
|
|
796
|
+
if (status === "pass" || status === "done" || status === "finished" || status === "accepted") return "success";
|
|
797
|
+
if (status === "fail" || status === "failed" || status === "failed_validation") return "danger";
|
|
798
|
+
if (status === "warn" || status === "plan_review" || status === "running") return "warning";
|
|
799
|
+
if (status === "not_run" || status === "open" || status === "draft" || status === "superseded") return "muted";
|
|
800
|
+
return "info";
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function badge(label, tone = "muted") {
|
|
804
|
+
return h("span", { class: "badge badge-" + tone, text: label || "-" });
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function dashboardTaskKey() {
|
|
808
|
+
return endpointState.dashboard.payload?.task?.id || apiTaskRef();
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function lazyJsonDetails(summary, getPayload, key) {
|
|
812
|
+
const details = bindDetailsState(h("details"), key);
|
|
813
|
+
const pre = h("pre", { class: "debug-json", text: "Open to render payload." });
|
|
814
|
+
const renderPayload = () => {
|
|
815
|
+
if (!details.open) return;
|
|
816
|
+
pre.textContent = JSON.stringify(getPayload() ?? {}, null, 2);
|
|
817
|
+
details.dataset.rendered = "true";
|
|
818
|
+
};
|
|
819
|
+
details.append(h("summary", { text: summary }), pre);
|
|
820
|
+
details.addEventListener("toggle", renderPayload);
|
|
821
|
+
queueMicrotask(renderPayload);
|
|
822
|
+
return details;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function jsonDetails(summary, payload, key) {
|
|
826
|
+
return lazyJsonDetails(summary, () => payload ?? {}, key || summary);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function copyCommand(command) {
|
|
830
|
+
if (!command) return;
|
|
831
|
+
navigator.clipboard?.writeText(command).catch(() => undefined);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function commandRow(command) {
|
|
835
|
+
if (!command) return emptyState("No command available.");
|
|
836
|
+
const button = h("button", { class: "copy-button", type: "button", text: "Copy" });
|
|
837
|
+
button.addEventListener("click", () => copyCommand(command));
|
|
838
|
+
return h("div", { class: "command-row" }, [h("code", { text: command }), button]);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function progressBar(done, total) {
|
|
842
|
+
const safeDone = Number(done || 0);
|
|
843
|
+
const safeTotal = Number(total || 0);
|
|
844
|
+
const pct = safeTotal > 0 ? Math.round((safeDone / safeTotal) * 100) : 0;
|
|
845
|
+
const track = h(
|
|
846
|
+
"div",
|
|
847
|
+
{
|
|
848
|
+
class: "progress-track",
|
|
849
|
+
role: "progressbar",
|
|
850
|
+
"aria-valuenow": pct,
|
|
851
|
+
"aria-valuemin": 0,
|
|
852
|
+
"aria-valuemax": 100,
|
|
853
|
+
"aria-label": pct + "% complete",
|
|
854
|
+
},
|
|
855
|
+
[h("div", { class: "progress-fill", style: "width:" + pct + "%" })]
|
|
856
|
+
);
|
|
857
|
+
return h("div", { class: "progress-block" }, [
|
|
858
|
+
h("strong", { text: safeDone + " / " + safeTotal }),
|
|
859
|
+
track,
|
|
860
|
+
h("span", { class: "muted", text: pct + "% complete" }),
|
|
861
|
+
]);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function endpointMessage(name, emptyText) {
|
|
865
|
+
const state = endpointState[name];
|
|
866
|
+
if (state.payload) return null;
|
|
867
|
+
if (state.error) return emptyText + " Error: " + state.error;
|
|
868
|
+
return emptyText;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function endpointOrFallback(name, emptyText) {
|
|
872
|
+
return endpointMessage(name, emptyText) || emptyText;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function cardSection(title, children, className = "") {
|
|
876
|
+
const section = h("section", { class: ("card " + className).trim() });
|
|
877
|
+
section.append(h("div", { class: "card-header" }, [h("h2", { text: title })]));
|
|
878
|
+
const list = (Array.isArray(children) ? children : [children]).flat(Infinity);
|
|
879
|
+
for (const child of list) {
|
|
880
|
+
if (child) section.append(child);
|
|
881
|
+
}
|
|
882
|
+
return section;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function collapsibleCard(title, children, key, className = "") {
|
|
886
|
+
const section = h("section", { class: ("card collapsible-card " + className).trim() });
|
|
887
|
+
const details = bindDetailsState(h("details", { class: "section-details" }), key);
|
|
888
|
+
details.append(h("summary", { text: title }));
|
|
889
|
+
const list = (Array.isArray(children) ? children : [children]).flat(Infinity);
|
|
890
|
+
for (const child of list) {
|
|
891
|
+
if (child) details.append(child);
|
|
892
|
+
}
|
|
893
|
+
section.append(details);
|
|
894
|
+
return section;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function renderTaskFilters() {
|
|
898
|
+
const container = document.getElementById("task-filters");
|
|
899
|
+
clearNode(container);
|
|
900
|
+
for (const filter of STAGE_FILTERS) {
|
|
901
|
+
const button = h("button", {
|
|
902
|
+
class: "chip",
|
|
903
|
+
type: "button",
|
|
904
|
+
text: filter.label,
|
|
905
|
+
"data-active": String(taskStageFilter === filter.value),
|
|
906
|
+
});
|
|
907
|
+
button.addEventListener("click", () => {
|
|
908
|
+
taskStageFilter = filter.value;
|
|
909
|
+
renderTasks();
|
|
910
|
+
});
|
|
911
|
+
container.append(button);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function taskMatchesFilter(task) {
|
|
916
|
+
if (taskStageFilter === "all") return true;
|
|
917
|
+
if (taskStageFilter === "active") return Boolean(task.active_stage);
|
|
918
|
+
if (taskStageFilter === "review") return task.status_stage === "plan_review";
|
|
919
|
+
if (taskStageFilter === "implementation") return task.active_stage === "implementation" || task.status_stage === "implemented";
|
|
920
|
+
if (taskStageFilter === "validation") return task.active_stage === "validation" || task.status_stage === "validating";
|
|
921
|
+
if (taskStageFilter === "failed") return task.status_stage === "failed_validation";
|
|
922
|
+
return task.status_stage === taskStageFilter;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function sortedTasks(tasks) {
|
|
926
|
+
return [...(tasks || [])].sort((left, right) => {
|
|
927
|
+
const leftStamp = left.updated_at || left.created_at || "";
|
|
928
|
+
const rightStamp = right.updated_at || right.created_at || "";
|
|
929
|
+
if (leftStamp || rightStamp) {
|
|
930
|
+
return String(rightStamp).localeCompare(String(leftStamp));
|
|
931
|
+
}
|
|
932
|
+
return String(right.id || "").localeCompare(String(left.id || ""));
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function renderTaskCard(task, currentTaskId) {
|
|
937
|
+
const selected = Boolean(task.id === selectedTaskRef || task.slug === selectedTaskRef || task.id === currentTaskId);
|
|
938
|
+
const button = h("button", { class: "task-card", type: "button", "data-active": String(selected) });
|
|
939
|
+
button.setAttribute("aria-current", selected ? "true" : "false");
|
|
940
|
+
button.addEventListener("click", () => {
|
|
941
|
+
selectedTaskRef = task.id;
|
|
942
|
+
refreshSelection().catch(renderError);
|
|
943
|
+
});
|
|
944
|
+
const idLabel = [task.id, task.slug].filter(Boolean).join(" · ");
|
|
945
|
+
const planLabel = task.accepted_plan_version
|
|
946
|
+
? "plan v" + task.accepted_plan_version + " accepted"
|
|
947
|
+
: task.latest_plan_version
|
|
948
|
+
? "plan v" + task.latest_plan_version + " proposed"
|
|
949
|
+
: "no plan";
|
|
950
|
+
const activeLabel = task.active_stage ? "active: " + task.active_stage : "active: none";
|
|
951
|
+
button.append(
|
|
952
|
+
h("div", { class: "badge-row" }, [
|
|
953
|
+
badge(task.active_stage ? titleCase(task.active_stage) : titleCase(task.status_stage || "draft"), toneForStage(task)),
|
|
954
|
+
task.priority ? badge("priority " + task.priority, "info") : null,
|
|
955
|
+
]),
|
|
956
|
+
h("p", { class: "task-title", text: task.title || task.slug || task.id }),
|
|
957
|
+
h("div", { class: "meta-line mono", text: idLabel || "-" }),
|
|
958
|
+
h("div", { class: "meta-line", text: activeLabel }),
|
|
959
|
+
h("div", { class: "meta-line", text: planLabel }),
|
|
960
|
+
task.description_summary ? h("div", { class: "summary-line", text: task.description_summary }) : null
|
|
961
|
+
);
|
|
962
|
+
return button;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function renderTasks() {
|
|
966
|
+
renderTaskFilters();
|
|
967
|
+
const tasksNode = document.getElementById("tasks");
|
|
968
|
+
clearNode(tasksNode);
|
|
969
|
+
const tasksPayload = endpointState.tasks.payload;
|
|
970
|
+
if (!tasksPayload) {
|
|
971
|
+
tasksNode.append(emptyState(endpointOrFallback("tasks", "Loading tasks...")));
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
const currentTaskId = endpointState.dashboard.payload?.task?.id || null;
|
|
975
|
+
const query = taskSearchQuery.trim().toLowerCase();
|
|
976
|
+
const tasks = sortedTasks(tasksPayload.tasks).filter((task) => {
|
|
977
|
+
const haystack = [task.title, task.slug, task.id].join(" ").toLowerCase();
|
|
978
|
+
return (!query || haystack.includes(query)) && taskMatchesFilter(task);
|
|
979
|
+
});
|
|
980
|
+
if (tasks.length === 0) {
|
|
981
|
+
tasksNode.append(emptyState("No tasks match the current search or filter."));
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
for (const task of tasks) {
|
|
985
|
+
tasksNode.append(renderTaskCard(task, currentTaskId));
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function renderHero(project, dashboard) {
|
|
990
|
+
if (!dashboard) {
|
|
991
|
+
return emptyState(endpointOrFallback("dashboard", "Loading dashboard summary..."));
|
|
992
|
+
}
|
|
993
|
+
const task = dashboard.task || {};
|
|
994
|
+
const lock = dashboard.lock;
|
|
995
|
+
const activeTask = project?.active_task || {};
|
|
996
|
+
const hero = h("section", { class: "card hero-card active-task-hero" });
|
|
997
|
+
hero.append(
|
|
998
|
+
h("div", { class: "hero-title" }, [
|
|
999
|
+
h("h2", { text: task.title || task.slug || task.id || "Active task" }),
|
|
1000
|
+
badge(task.status_stage || "unknown", toneForStage(task)),
|
|
1001
|
+
task.active_stage ? badge("active " + task.active_stage, "info") : badge("no active lock", "muted"),
|
|
1002
|
+
]),
|
|
1003
|
+
h("p", { class: "section-subtitle", text: task.description_summary || "Human-focused read-only review of the selected task." }),
|
|
1004
|
+
h("div", { class: "hero-meta" }, [
|
|
1005
|
+
h("div", { class: "meta-row" }, [h("span", { class: "muted", text: "Task reference" }), h("strong", { class: "mono", text: [task.id, task.slug].filter(Boolean).join(" · ") || "-" })]),
|
|
1006
|
+
h("div", { class: "meta-row" }, [h("span", { class: "muted", text: "Lock state" }), h("strong", { text: lock ? lock.stage + " · " + lock.run_id : "No active lock" })]),
|
|
1007
|
+
h("div", { class: "meta-row" }, [h("span", { class: "muted", text: "Plan status" }), h("strong", { text: dashboard.plan ? "v" + dashboard.plan.version + " · " + dashboard.plan.status : "No plan proposed" })]),
|
|
1008
|
+
h("div", { class: "meta-row" }, [h("span", { class: "muted", text: "Project focus" }), h("strong", { text: activeTask.task_id ? (activeTask.slug || activeTask.task_id) + " · " + (project?.health || "not_checked") : project?.health || "not_checked" })]),
|
|
1009
|
+
]),
|
|
1010
|
+
h("div", { class: "pill-row" }, [
|
|
1011
|
+
task.owner ? badge("owner " + task.owner, "info") : null,
|
|
1012
|
+
...(task.labels || []).map((label) => badge(label, "muted")),
|
|
1013
|
+
task.created_at ? badge("created " + formatTimestamp(task.created_at), "muted") : null,
|
|
1014
|
+
task.updated_at ? badge("updated " + formatTimestamp(task.updated_at), "muted") : null,
|
|
1015
|
+
])
|
|
1016
|
+
);
|
|
1017
|
+
return hero;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function renderMetrics(dashboard, events) {
|
|
1021
|
+
if (!dashboard) {
|
|
1022
|
+
return [emptyState(endpointOrFallback("dashboard", "Loading progress overview..."))];
|
|
1023
|
+
}
|
|
1024
|
+
const validationCriteria = dashboard.validation?.criteria || [];
|
|
1025
|
+
const passedValidation = validationCriteria.filter((item) => item.satisfied).length;
|
|
1026
|
+
const cards = [
|
|
1027
|
+
{ title: "Todos", detail: (dashboard.todos?.done || 0) + " complete of " + (dashboard.todos?.total || 0), body: progressBar(dashboard.todos?.done || 0, dashboard.todos?.total || 0) },
|
|
1028
|
+
{ title: "Questions", detail: (dashboard.questions?.open || 0) + " open of " + (dashboard.questions?.total || 0), body: progressBar((dashboard.questions?.total || 0) - (dashboard.questions?.open || 0), dashboard.questions?.total || 0) },
|
|
1029
|
+
{ title: "Validation", detail: passedValidation + " satisfied of " + validationCriteria.length, body: progressBar(passedValidation, validationCriteria.length) },
|
|
1030
|
+
{ title: "Recent activity", detail: (events?.items || []).length + " recent entries", body: h("div", { class: "metric-value" }, [h("strong", { class: "metric-number", text: String((events?.items || []).length) }), h("span", { class: "muted", text: "Recent event tail" })]) },
|
|
1031
|
+
];
|
|
1032
|
+
return cards.map((metric) => h("section", { class: "card" }, [h("div", { class: "card-header" }, [h("h2", { text: metric.title }), h("span", { class: "muted", text: metric.detail })]), metric.body]));
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function renderOverview(project, dashboard) {
|
|
1036
|
+
if (!project && !dashboard) return emptyState(endpointOrFallback("project", "Loading workspace summary..."));
|
|
1037
|
+
return h("div", { class: "list-grid" }, [
|
|
1038
|
+
h("div", { class: "item-card" }, [h("div", { class: "item-title" }, [h("strong", { text: "Workspace" })]), h("div", { class: "mini-meta" }, [h("span", { class: "muted", text: "Workspace root" }), h("code", { text: project?.workspace_root || "-" }), h("span", { class: "muted", text: "Project dir" }), h("code", { text: project?.project_dir || "-" })])]),
|
|
1039
|
+
h("div", { class: "item-card" }, [h("div", { class: "item-title" }, [h("strong", { text: "Selected task state" })]), h("div", { class: "mini-meta" }, [h("span", { class: "muted", text: "Stage" }), h("span", { text: dashboard?.task?.status_stage || "-" }), h("span", { class: "muted", text: "Active stage" }), h("span", { text: dashboard?.task?.active_stage || "none" }), h("span", { class: "muted", text: "Health" }), h("span", { text: project?.health || "unknown" })])]),
|
|
1040
|
+
]);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function renderNextAction(dashboard) {
|
|
1044
|
+
const nextAction = dashboard?.next_action;
|
|
1045
|
+
if (!nextAction) return emptyState(endpointOrFallback("dashboard", "Loading next action..."));
|
|
1046
|
+
const task = dashboard?.task || {};
|
|
1047
|
+
const blockers = nextAction.blocking || [];
|
|
1048
|
+
const nextItem = nextAction.next_item;
|
|
1049
|
+
const todoProgress = nextAction.progress?.todos || {};
|
|
1050
|
+
const card = h("section", { class: "card next-action-card" });
|
|
1051
|
+
card.append(h("div", { class: "card-header" }, [h("h2", { text: "Do next" }), badge(nextAction.action || "none", toneForStatus(nextAction.action || "none"))]), h("p", { class: "section-subtitle", text: nextAction.reason || "No next action available." }));
|
|
1052
|
+
card.append(h("div", { class: "item-card" }, [h("div", { class: "item-title" }, [h("strong", { text: task.title || task.slug || "Selected task" }), task.id ? h("code", { text: task.id }) : null]), h("div", { class: "mini-meta" }, [h("span", { class: "muted", text: "Stage" }), h("span", { text: task.status_stage || "-" }), h("span", { class: "muted", text: "Active" }), h("span", { text: task.active_stage || "none" })])]));
|
|
1053
|
+
if (nextAction.next_command) {
|
|
1054
|
+
card.append(h("div", { class: "item-card" }, [h("div", { class: "item-title" }, [h("strong", { text: "Inspect" })]), commandRow(nextAction.next_command)]));
|
|
1055
|
+
}
|
|
1056
|
+
if (nextItem) {
|
|
1057
|
+
card.append(h("div", { class: "item-card" }, [h("div", { class: "item-title" }, [h("strong", { text: nextItem.id || "Next item" }), nextItem.kind ? badge(nextItem.kind, "info") : null]), nextItem.text ? h("p", { text: nextItem.text }) : null, nextItem.validation_hint ? h("div", { class: "mini-meta" }, [h("span", { class: "muted", text: "Validation" })]) : null, nextItem.validation_hint ? commandRow(nextItem.validation_hint) : null, nextItem.done_command_hint ? h("div", { class: "mini-meta" }, [h("span", { class: "muted", text: "When done" })]) : null, nextItem.done_command_hint ? commandRow(nextItem.done_command_hint) : null]));
|
|
1058
|
+
}
|
|
1059
|
+
if (Object.keys(todoProgress).length > 0) {
|
|
1060
|
+
card.append(h("div", { class: "item-card" }, [h("div", { class: "item-title" }, [h("strong", { text: "Todo progress" })]), h("p", { text: String(todoProgress.done || 0) + "/" + String(todoProgress.total || 0) + " done" })]));
|
|
1061
|
+
}
|
|
1062
|
+
if (blockers.length > 0) {
|
|
1063
|
+
card.append(h("div", { class: "list-grid" }, [h("h3", { text: "Blockers" }), h("ul", { class: "clean-list" }, blockers.map((blocker) => h("li", { text: blocker.message || blocker.kind || "Blocking issue" }))) ]));
|
|
1064
|
+
}
|
|
1065
|
+
return card;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function renderQuestionsSection(questions) {
|
|
1069
|
+
if (!questions) return emptyState(endpointOrFallback("dashboard", "Loading questions..."));
|
|
1070
|
+
if (!questions.items || questions.items.length === 0) return h("p", { class: "section-subtitle", text: "No planning questions are recorded." });
|
|
1071
|
+
return h("div", { class: "list-grid" }, questions.items.map((item) => h("div", { class: "item-card" }, [h("div", { class: "item-title" }, [h("strong", { text: item.question || item.text || item.id }), badge(item.status || "open", toneForStatus(item.status || "open"))]), h("code", { text: item.id || "-" }), item.answer ? h("p", { text: item.answer }) : null])));
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function renderPlanSection(plans) {
|
|
1075
|
+
if (!plans || plans.length === 0) return emptyState("No plans have been proposed yet.");
|
|
1076
|
+
const taskKey = dashboardTaskKey();
|
|
1077
|
+
const latest = plans[plans.length - 1];
|
|
1078
|
+
const body = h("div", { class: "list-grid" }, [h("p", { class: "section-subtitle", text: "Latest plan v" + latest.plan_version + " · " + (latest.status || "unknown") })]);
|
|
1079
|
+
if (latest.goal) body.append(h("p", { text: latest.goal }));
|
|
1080
|
+
if (latest.criteria?.length) {
|
|
1081
|
+
body.append(h("div", { class: "criteria-grid" }, latest.criteria.map((criterion) => h("div", { class: "item-card" }, [h("div", { class: "item-title" }, [h("strong", { text: criterion.text || criterion.id }), criterion.id ? h("code", { text: criterion.id }) : null]), badge(criterion.mandatory === false ? "optional" : "mandatory", criterion.mandatory === false ? "muted" : "info")]))));
|
|
1082
|
+
}
|
|
1083
|
+
if (latest.todos?.length) {
|
|
1084
|
+
body.append(h("div", { class: "list-grid" }, latest.todos.map((todo) => h("div", { class: "item-card" }, [h("div", { class: "item-title" }, [h("strong", { text: todo.text || todo.id }), todo.id ? h("code", { text: todo.id }) : null]), todo.validation_hint ? commandRow(todo.validation_hint) : null]))));
|
|
1085
|
+
}
|
|
1086
|
+
if (latest.test_commands?.length) {
|
|
1087
|
+
body.append(h("div", { class: "list-grid" }, [h("h3", { text: "Test commands" }), ...latest.test_commands.map((command) => commandRow(command))]));
|
|
1088
|
+
}
|
|
1089
|
+
if (latest.expected_outputs?.length) {
|
|
1090
|
+
body.append(h("ul", { class: "clean-list" }, latest.expected_outputs.map((item) => h("li", { text: item }))));
|
|
1091
|
+
}
|
|
1092
|
+
if (latest.body) body.append(jsonDetails("Expanded plan body", latest.body, "plan.body." + taskKey + ".v" + latest.plan_version));
|
|
1093
|
+
if (plans.length > 1) {
|
|
1094
|
+
const details = bindDetailsState(h("details"), "plan.previous_versions." + taskKey);
|
|
1095
|
+
details.append(h("summary", { text: "Previous versions" }), ...plans.slice(0, -1).reverse().map((plan) => h("div", { class: "item-card" }, [h("div", { class: "item-title" }, [h("strong", { text: "v" + plan.plan_version }), badge(plan.status || "unknown", toneForStatus(plan.status || "unknown"))]), plan.goal ? h("p", { text: plan.goal }) : null, plan.body ? h("pre", { text: plan.body }) : null])));
|
|
1096
|
+
body.append(details);
|
|
1097
|
+
}
|
|
1098
|
+
return body;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function renderTodosSection(todos, nextAction) {
|
|
1102
|
+
if (!todos) return emptyState(endpointOrFallback("dashboard", "Loading todos..."));
|
|
1103
|
+
const highlightedTodoId = nextAction?.next_item?.kind === "todo" ? nextAction.next_item.id : null;
|
|
1104
|
+
const items = [...(todos.items || [])].sort((left, right) => Number(Boolean(left.done)) - Number(Boolean(right.done)));
|
|
1105
|
+
const taskKey = dashboardTaskKey();
|
|
1106
|
+
const todoCards = items.map((todo) => {
|
|
1107
|
+
const done = Boolean(todo.done || todo.status === "done");
|
|
1108
|
+
const card = h("div", { class: "item-card" + (todo.id === highlightedTodoId ? " todo-next" : "") }, [h("div", { class: "item-title" }, [h("strong", { text: todo.text || todo.id }), badge(done ? "done" : todo.status || "open", toneForStatus(done ? "done" : todo.status || "open"))]), h("code", { text: todo.id || "-" })]);
|
|
1109
|
+
const lines = [];
|
|
1110
|
+
if (todo.evidence) lines.push("Evidence: " + todo.evidence);
|
|
1111
|
+
if (todo.source) lines.push("Source: " + todo.source);
|
|
1112
|
+
if (todo.active_at) lines.push("Active at: " + todo.active_at);
|
|
1113
|
+
if (lines.length > 0) {
|
|
1114
|
+
const details = bindDetailsState(h("details"), "todo." + taskKey + "." + (todo.id || todo.text || "todo") + ".details");
|
|
1115
|
+
details.append(h("summary", { text: "Details" }), h("ul", { class: "clean-list" }, lines.map((line) => h("li", { text: line }))));
|
|
1116
|
+
card.append(details);
|
|
1117
|
+
}
|
|
1118
|
+
return card;
|
|
1119
|
+
});
|
|
1120
|
+
return h("div", { class: "list-grid" }, [
|
|
1121
|
+
h("p", { class: "section-subtitle", text: (todos.done || 0) + " done of " + (todos.total || 0) + " total" }),
|
|
1122
|
+
progressBar(todos.done || 0, todos.total || 0),
|
|
1123
|
+
items.length === 0 ? emptyState("No todos are recorded.") : h("div", { class: "list-grid" }, todoCards),
|
|
1124
|
+
]);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function renderValidationSection(validation) {
|
|
1128
|
+
if (!validation) return emptyState(endpointOrFallback("dashboard", "Loading validation..."));
|
|
1129
|
+
const taskKey = dashboardTaskKey();
|
|
1130
|
+
const parts = [h("p", { class: "section-subtitle", text: validation.run_id ? "Validation run " + validation.run_id + " · " + (validation.can_finish_passed ? "ready to finish" : "checks remain") : "No validation run recorded" })];
|
|
1131
|
+
if ((validation.blockers || []).length > 0) {
|
|
1132
|
+
parts.push(h("div", { class: "item-card" }, [h("div", { class: "item-title" }, [h("strong", { text: "Blockers" })]), h("ul", { class: "clean-list" }, validation.blockers.map((blocker) => h("li", { text: blocker.message || blocker.ref || blocker.kind || "Blocking issue" }))) ]));
|
|
1133
|
+
}
|
|
1134
|
+
parts.push((validation.criteria || []).length === 0 ? emptyState("No validation criteria were found.") : h("div", { class: "criteria-grid" }, validation.criteria.map((criterion) => {
|
|
1135
|
+
const card = h("div", { class: "item-card" }, [h("div", { class: "item-title" }, [h("strong", { text: criterion.text || criterion.id }), badge(criterion.latest_status || "not_run", toneForStatus(criterion.latest_status || "not_run"))]), h("code", { text: criterion.id || "-" }), criterion.has_waiver ? badge("waived", "warning") : null, criterion.evidence?.length ? h("ul", { class: "clean-list" }, criterion.evidence.map((item) => h("li", { text: item }))) : h("p", { class: "muted", text: "No evidence recorded." })]);
|
|
1136
|
+
if (criterion.history?.length || criterion.blockers?.length) {
|
|
1137
|
+
const details = bindDetailsState(h("details"), "validation." + taskKey + "." + (criterion.id || "criterion") + ".history");
|
|
1138
|
+
details.append(h("summary", { text: "History and blockers" }), criterion.history?.length ? h("ul", { class: "clean-list" }, criterion.history.map((item) => h("li", { text: (item.check_id || "check") + " · " + (item.status || "unknown") }))) : null, criterion.blockers?.length ? h("ul", { class: "clean-list" }, criterion.blockers.map((item) => h("li", { text: item.message || item.kind || "blocker" }))) : null);
|
|
1139
|
+
card.append(details);
|
|
1140
|
+
}
|
|
1141
|
+
return card;
|
|
1142
|
+
})));
|
|
1143
|
+
return h("div", { class: "list-grid" }, parts);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function renderRunsSection(runs) {
|
|
1147
|
+
if (!runs) return emptyState(endpointOrFallback("dashboard", "Loading runs..."));
|
|
1148
|
+
if (runs.length === 0) return emptyState("No implementation or validation runs are recorded.");
|
|
1149
|
+
const taskKey = dashboardTaskKey();
|
|
1150
|
+
return h("div", { class: "timeline" }, runs.map((run) => {
|
|
1151
|
+
const card = h("div", { class: "timeline-item" }, [h("div", { class: "item-title" }, [h("strong", { text: run.run_id + " · " + titleCase(run.run_type || "run") }), badge(run.result || run.status || "unknown", toneForStatus(run.result || run.status || "unknown"))]), h("p", { text: run.summary || "No summary recorded." }), h("div", { class: "mini-meta" }, [h("span", { class: "muted", text: "Started" }), h("span", { text: formatTimestamp(run.started_at) }), h("span", { class: "muted", text: "Finished" }), h("span", { text: run.finished_at ? formatTimestamp(run.finished_at) : "In progress" })])]);
|
|
1152
|
+
card.append(jsonDetails("Run details", run, "run." + taskKey + "." + (run.run_id || "run") + ".details"));
|
|
1153
|
+
return card;
|
|
1154
|
+
}));
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function renderChangesSection(changes) {
|
|
1158
|
+
if (!changes) return emptyState(endpointOrFallback("dashboard", "Loading changes..."));
|
|
1159
|
+
if (changes.length === 0) return emptyState("No implementation changes are recorded.");
|
|
1160
|
+
const taskKey = dashboardTaskKey();
|
|
1161
|
+
return h("div", { class: "change-grid" }, changes.map((change) => {
|
|
1162
|
+
const card = h("div", { class: "item-card" }, [h("div", { class: "item-title" }, [h("strong", { text: change.summary || change.path || change.change_id }), badge(change.kind || "change", "info")]), h("code", { text: change.path || change.change_id || "-" })]);
|
|
1163
|
+
card.append(jsonDetails("Change metadata", change, "change." + taskKey + "." + (change.change_id || change.path || "change") + ".metadata"));
|
|
1164
|
+
return card;
|
|
1165
|
+
}));
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function renderEventsSection(events) {
|
|
1169
|
+
if (!events) return emptyState(endpointOrFallback("events", "Loading events..."));
|
|
1170
|
+
if (!events.items || events.items.length === 0) return emptyState("No recent events are available.");
|
|
1171
|
+
const taskKey = dashboardTaskKey();
|
|
1172
|
+
return h("div", { class: "timeline" }, events.items.map((event, index) => {
|
|
1173
|
+
const actor = event.actor?.actor_name || event.actor?.actor_type || "unknown";
|
|
1174
|
+
const card = h("div", { class: "timeline-item" }, [h("div", { class: "item-title" }, [h("strong", { text: event.event || "event" }), h("span", { class: "muted mono", text: formatTimestamp(event.ts) })]), h("p", { text: actor })]);
|
|
1175
|
+
card.append(jsonDetails("Event payload", event, "event." + taskKey + "." + (event.event_id || event.ts || String(index)) + ".payload"));
|
|
1176
|
+
return card;
|
|
1177
|
+
}));
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function renderRecentEventsCard(events) {
|
|
1181
|
+
return cardSection("Recent events", [
|
|
1182
|
+
h("p", { class: "section-subtitle", text: "Recent activity stays visible while secondary detail stays collapsed." }),
|
|
1183
|
+
renderEventsSection(events),
|
|
1184
|
+
]);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function renderRawSection(project, dashboard, events) {
|
|
1188
|
+
const taskKey = dashboardTaskKey();
|
|
1189
|
+
return collapsibleCard(
|
|
1190
|
+
"Debug / raw payload",
|
|
1191
|
+
[
|
|
1192
|
+
h("p", { class: "section-subtitle", text: "Debug payloads stay available without dominating the main dashboard." }),
|
|
1193
|
+
h("div", { class: "raw-payload stack-gap" }, [
|
|
1194
|
+
lazyJsonDetails("Project payload", () => project || {}, "raw.project"),
|
|
1195
|
+
lazyJsonDetails("Dashboard payload", () => dashboard || {}, "raw.dashboard." + taskKey),
|
|
1196
|
+
lazyJsonDetails("Events payload", () => events || {}, "raw.events." + taskKey),
|
|
1197
|
+
]),
|
|
1198
|
+
],
|
|
1199
|
+
"section.debug." + taskKey,
|
|
1200
|
+
"raw-payload",
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
function renderSections() {
|
|
1205
|
+
const project = endpointState.project.payload;
|
|
1206
|
+
const dashboard = endpointState.dashboard.payload;
|
|
1207
|
+
const events = endpointState.events.payload;
|
|
1208
|
+
const taskKey = dashboardTaskKey();
|
|
1209
|
+
const heroSlot = document.getElementById("hero-slot");
|
|
1210
|
+
heroSlot?.replaceChildren(renderHero(project, dashboard));
|
|
1211
|
+
const metricGrid = document.getElementById("metric-grid");
|
|
1212
|
+
metricGrid?.replaceChildren(...renderMetrics(dashboard, events));
|
|
1213
|
+
const rail = document.getElementById("rail-content");
|
|
1214
|
+
replaceContentPreservingDetails(rail, [
|
|
1215
|
+
renderNextAction(dashboard),
|
|
1216
|
+
renderRecentEventsCard(events),
|
|
1217
|
+
]);
|
|
1218
|
+
const sections = document.getElementById("sections");
|
|
1219
|
+
replaceContentPreservingDetails(sections, [
|
|
1220
|
+
cardSection("Current summary", renderOverview(project, dashboard)),
|
|
1221
|
+
cardSection("Todos", renderTodosSection(dashboard?.todos, dashboard?.next_action)),
|
|
1222
|
+
collapsibleCard("Plan", renderPlanSection(dashboard?.plans), "section.plan." + taskKey),
|
|
1223
|
+
collapsibleCard("Questions", renderQuestionsSection(dashboard?.questions), "section.questions." + taskKey),
|
|
1224
|
+
collapsibleCard("Validation", renderValidationSection(dashboard?.validation), "section.validation-card." + taskKey),
|
|
1225
|
+
collapsibleCard("Runs", renderRunsSection(dashboard?.runs), "section.runs." + taskKey),
|
|
1226
|
+
collapsibleCard("Changes", renderChangesSection(dashboard?.changes), "section.changes." + taskKey),
|
|
1227
|
+
renderRawSection(project, dashboard, events),
|
|
1228
|
+
]);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
function setStatus() {
|
|
1232
|
+
const errors = Object.entries(endpointState).filter(([, state]) => Boolean(state.error)).map(([name, state]) => name + " error: " + state.error);
|
|
1233
|
+
const headline = document.getElementById("status-headline");
|
|
1234
|
+
const detail = document.getElementById("status-detail");
|
|
1235
|
+
const selected = document.getElementById("selected-task-label");
|
|
1236
|
+
const updated = document.getElementById("last-updated-label");
|
|
1237
|
+
const liveStatus = document.getElementById("live-status-label");
|
|
1238
|
+
const toggleButton = document.getElementById("toggle-polling-button");
|
|
1239
|
+
const refreshButton = document.getElementById("refresh-now-button");
|
|
1240
|
+
const reviewState = refreshInFlight ? "Refreshing" : (pollingPaused ? "Paused" : "Live");
|
|
1241
|
+
if (headline) {
|
|
1242
|
+
headline.textContent = reviewState === "Paused"
|
|
1243
|
+
? "Dashboard updates are paused for focused review."
|
|
1244
|
+
: reviewState === "Refreshing"
|
|
1245
|
+
? "Refreshing dashboard data..."
|
|
1246
|
+
: "Showing a read-only review of the selected task.";
|
|
1247
|
+
}
|
|
1248
|
+
if (detail) {
|
|
1249
|
+
detail.textContent = errors.length > 0
|
|
1250
|
+
? errors.join(" · ")
|
|
1251
|
+
: pollingPaused
|
|
1252
|
+
? "Automatic polling is paused. Use Refresh now to fetch current task state on demand."
|
|
1253
|
+
: "Polling dashboard and events every " + refreshMs + "ms with slower project and task refresh cadences.";
|
|
1254
|
+
}
|
|
1255
|
+
if (selected) {
|
|
1256
|
+
selected.textContent = endpointState.dashboard.payload?.task?.slug || apiTaskRef();
|
|
1257
|
+
}
|
|
1258
|
+
if (updated) {
|
|
1259
|
+
updated.textContent = lastUpdatedText;
|
|
1260
|
+
}
|
|
1261
|
+
if (liveStatus) {
|
|
1262
|
+
liveStatus.textContent = reviewState;
|
|
1263
|
+
liveStatus.className = reviewState === "Paused"
|
|
1264
|
+
? "status-paused"
|
|
1265
|
+
: reviewState === "Refreshing"
|
|
1266
|
+
? "status-refreshing"
|
|
1267
|
+
: "status-live";
|
|
1268
|
+
}
|
|
1269
|
+
if (toggleButton) {
|
|
1270
|
+
toggleButton.textContent = pollingPaused ? "Resume updates" : "Pause updates";
|
|
1271
|
+
}
|
|
1272
|
+
if (refreshButton) {
|
|
1273
|
+
refreshButton.disabled = refreshInFlight;
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
function togglePolling() {
|
|
1278
|
+
pollingPaused = !pollingPaused;
|
|
1279
|
+
setStatus();
|
|
1280
|
+
if (!pollingPaused) {
|
|
1281
|
+
refresh().catch(renderError);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function shouldRefresh(name, now) {
|
|
1286
|
+
const state = endpointState[name];
|
|
1287
|
+
const path = endpointPath(name);
|
|
1288
|
+
if (state.key !== path) return true;
|
|
1289
|
+
if (state.payload === null) return true;
|
|
1290
|
+
return (now - state.lastRequestedAt) >= endpointCadence(name);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
async function refreshEndpoint(name, now) {
|
|
1294
|
+
const state = endpointState[name];
|
|
1295
|
+
state.lastRequestedAt = now;
|
|
1296
|
+
try {
|
|
1297
|
+
const result = await getJson(name, endpointPath(name));
|
|
1298
|
+
state.error = null;
|
|
1299
|
+
return result.changed;
|
|
1300
|
+
} catch (error) {
|
|
1301
|
+
const previousError = state.error;
|
|
1302
|
+
state.error = String(error);
|
|
1303
|
+
return state.payload === null || state.error !== previousError;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
async function refresh() {
|
|
1308
|
+
if (refreshInFlight) return;
|
|
1309
|
+
refreshInFlight = true;
|
|
1310
|
+
const changed = new Set();
|
|
1311
|
+
setStatus();
|
|
1312
|
+
try {
|
|
1313
|
+
const now = Date.now();
|
|
1314
|
+
const work = [];
|
|
1315
|
+
for (const name of ["project", "tasks", "dashboard", "events"]) {
|
|
1316
|
+
if (shouldRefresh(name, now)) {
|
|
1317
|
+
work.push(
|
|
1318
|
+
refreshEndpoint(name, now).then((didChange) => {
|
|
1319
|
+
if (didChange) changed.add(name);
|
|
1320
|
+
})
|
|
1321
|
+
);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
await Promise.allSettled(work);
|
|
1325
|
+
lastUpdatedText = new Date().toLocaleTimeString();
|
|
1326
|
+
} finally {
|
|
1327
|
+
refreshInFlight = false;
|
|
1328
|
+
if (changed.has("tasks")) renderTasks();
|
|
1329
|
+
if (changed.has("project") || changed.has("dashboard") || changed.has("events")) {
|
|
1330
|
+
renderSections();
|
|
1331
|
+
}
|
|
1332
|
+
setStatus();
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
async function refreshSelection() {
|
|
1337
|
+
endpointState.dashboard.key = null;
|
|
1338
|
+
endpointState.dashboard.etag = null;
|
|
1339
|
+
endpointState.dashboard.payload = null;
|
|
1340
|
+
endpointState.dashboard.error = null;
|
|
1341
|
+
endpointState.dashboard.lastRequestedAt = 0;
|
|
1342
|
+
endpointState.events.key = null;
|
|
1343
|
+
endpointState.events.payload = null;
|
|
1344
|
+
endpointState.events.lastRequestedAt = 0;
|
|
1345
|
+
endpointState.events.etag = null;
|
|
1346
|
+
endpointState.events.error = null;
|
|
1347
|
+
renderTasks();
|
|
1348
|
+
renderSections();
|
|
1349
|
+
setStatus();
|
|
1350
|
+
await refresh();
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
function scheduleRefresh(delay = refreshMs) {
|
|
1354
|
+
clearTimeout(refreshTimer);
|
|
1355
|
+
refreshTimer = setTimeout(() => {
|
|
1356
|
+
if (pollingPaused) {
|
|
1357
|
+
setStatus();
|
|
1358
|
+
scheduleRefresh();
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
refresh().catch(renderError).finally(() => scheduleRefresh());
|
|
1362
|
+
}, delay);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
function renderError(error) {
|
|
1366
|
+
endpointState.dashboard.error = String(error);
|
|
1367
|
+
setStatus();
|
|
1368
|
+
renderSections();
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
function setupControls() {
|
|
1372
|
+
const search = document.getElementById("task-search");
|
|
1373
|
+
search?.addEventListener("input", (event) => {
|
|
1374
|
+
taskSearchQuery = event.target?.value || "";
|
|
1375
|
+
renderTasks();
|
|
1376
|
+
});
|
|
1377
|
+
document.getElementById("toggle-polling-button")?.addEventListener("click", () => {
|
|
1378
|
+
togglePolling();
|
|
1379
|
+
});
|
|
1380
|
+
document.getElementById("refresh-now-button")?.addEventListener("click", () => {
|
|
1381
|
+
refresh().catch(renderError);
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
setupControls();
|
|
1386
|
+
renderTasks();
|
|
1387
|
+
renderSections();
|
|
1388
|
+
setStatus();
|
|
1389
|
+
refresh().catch(renderError).finally(() => scheduleRefresh());
|
|
1390
|
+
</script>
|
|
1391
|
+
"""
|
|
1392
|
+
)
|
|
1393
|
+
return (
|
|
1394
|
+
script.replace("__REFRESH_MS__", json.dumps(refresh_ms))
|
|
1395
|
+
.replace("__DEFAULT_TASK_REF__", _safe_script_literal(task_ref))
|
|
1396
|
+
.strip()
|
|
1397
|
+
)
|
|
1398
|
+
|
|
1399
|
+
|
|
1400
|
+
def launch_dashboard_server(config: DashboardServerConfig) -> DashboardServerHandle:
|
|
1401
|
+
_validate_host(config.host)
|
|
1402
|
+
if config.port < 0 or config.port > 65535:
|
|
1403
|
+
raise LaunchError("taskledger serve requires --port between 0 and 65535.")
|
|
1404
|
+
if config.refresh_ms <= 0:
|
|
1405
|
+
raise LaunchError("taskledger serve requires --refresh-ms greater than 0.")
|
|
1406
|
+
server = _create_server(config)
|
|
1407
|
+
host = config.host
|
|
1408
|
+
port = int(server.server_address[1])
|
|
1409
|
+
url = _server_url(host, port)
|
|
1410
|
+
handle = DashboardServerHandle(server=server, host=host, port=port, url=url)
|
|
1411
|
+
if config.open_browser:
|
|
1412
|
+
webbrowser.open(url)
|
|
1413
|
+
return handle
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
def serve_dashboard(config: DashboardServerConfig) -> None:
|
|
1417
|
+
handle = launch_dashboard_server(config)
|
|
1418
|
+
try:
|
|
1419
|
+
handle.serve_forever()
|
|
1420
|
+
finally:
|
|
1421
|
+
handle.close()
|
|
1422
|
+
|
|
1423
|
+
|
|
1424
|
+
def _validate_host(host: str) -> None:
|
|
1425
|
+
if host not in {"127.0.0.1", "localhost", "::1"}:
|
|
1426
|
+
raise LaunchError("taskledger serve only binds to localhost in the MVP.")
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
def _create_server(config: DashboardServerConfig) -> _DashboardHTTPServer:
|
|
1430
|
+
address_family = socket.AF_INET6 if ":" in config.host else socket.AF_INET
|
|
1431
|
+
|
|
1432
|
+
class DashboardHTTPServer(_DashboardHTTPServer):
|
|
1433
|
+
pass
|
|
1434
|
+
|
|
1435
|
+
DashboardHTTPServer.address_family = address_family
|
|
1436
|
+
server = DashboardHTTPServer((config.host, config.port), _DashboardRequestHandler)
|
|
1437
|
+
server.workspace_root = config.workspace_root
|
|
1438
|
+
server.default_task_ref = config.task_ref
|
|
1439
|
+
server.refresh_ms = config.refresh_ms
|
|
1440
|
+
server.cache = {}
|
|
1441
|
+
return server
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
class _DashboardRequestHandler(BaseHTTPRequestHandler):
|
|
1445
|
+
server: _DashboardHTTPServer
|
|
1446
|
+
|
|
1447
|
+
def do_GET(self) -> None: # noqa: N802
|
|
1448
|
+
parsed = urlparse(self.path)
|
|
1449
|
+
query = parse_qs(parsed.query)
|
|
1450
|
+
try:
|
|
1451
|
+
if parsed.path == "/":
|
|
1452
|
+
self._send_text(
|
|
1453
|
+
200,
|
|
1454
|
+
render_index_html(
|
|
1455
|
+
refresh_ms=self.server.refresh_ms,
|
|
1456
|
+
task_ref=self.server.default_task_ref,
|
|
1457
|
+
),
|
|
1458
|
+
content_type="text/html; charset=utf-8",
|
|
1459
|
+
)
|
|
1460
|
+
return
|
|
1461
|
+
if parsed.path == "/api/project":
|
|
1462
|
+
revision = _storage_revision_for_project(self.server.workspace_root)
|
|
1463
|
+
self._send_cached_json(
|
|
1464
|
+
200,
|
|
1465
|
+
revision,
|
|
1466
|
+
lambda: serve_project_summary(self.server.workspace_root),
|
|
1467
|
+
)
|
|
1468
|
+
return
|
|
1469
|
+
if parsed.path == "/api/tasks":
|
|
1470
|
+
revision = _storage_revision_for_tasks(self.server.workspace_root)
|
|
1471
|
+
self._send_cached_json(
|
|
1472
|
+
200,
|
|
1473
|
+
revision,
|
|
1474
|
+
lambda: serve_task_summaries(self.server.workspace_root),
|
|
1475
|
+
)
|
|
1476
|
+
return
|
|
1477
|
+
if parsed.path == "/api/dashboard":
|
|
1478
|
+
task_ref = _task_ref_from_query(query, self.server.default_task_ref)
|
|
1479
|
+
revision = _storage_revision_for_dashboard(
|
|
1480
|
+
self.server.workspace_root, task_ref
|
|
1481
|
+
)
|
|
1482
|
+
self._send_cached_json(
|
|
1483
|
+
200,
|
|
1484
|
+
revision,
|
|
1485
|
+
lambda: serve_dashboard_snapshot(
|
|
1486
|
+
self.server.workspace_root,
|
|
1487
|
+
ref=task_ref,
|
|
1488
|
+
),
|
|
1489
|
+
)
|
|
1490
|
+
return
|
|
1491
|
+
if parsed.path == "/api/events":
|
|
1492
|
+
task_ref = _task_ref_from_query(query, self.server.default_task_ref)
|
|
1493
|
+
revision = _storage_revision_for_events(
|
|
1494
|
+
self.server.workspace_root, task_ref
|
|
1495
|
+
)
|
|
1496
|
+
self._send_cached_json(
|
|
1497
|
+
200,
|
|
1498
|
+
revision,
|
|
1499
|
+
lambda: serve_task_events(
|
|
1500
|
+
self.server.workspace_root,
|
|
1501
|
+
ref=task_ref,
|
|
1502
|
+
limit=_limit_from_query(query),
|
|
1503
|
+
),
|
|
1504
|
+
)
|
|
1505
|
+
return
|
|
1506
|
+
self._send_api_error(404, "NotFound", f"Unknown path: {parsed.path}")
|
|
1507
|
+
except LaunchError as exc:
|
|
1508
|
+
error_status, error_type = _status_for_launch_error(exc)
|
|
1509
|
+
self._send_api_error(error_status, error_type, str(exc))
|
|
1510
|
+
except ValueError as exc:
|
|
1511
|
+
self._send_api_error(400, "BadRequest", str(exc))
|
|
1512
|
+
except Exception as exc: # noqa: BLE001
|
|
1513
|
+
self._send_api_error(500, "InternalError", str(exc))
|
|
1514
|
+
|
|
1515
|
+
def do_POST(self) -> None: # noqa: N802
|
|
1516
|
+
self._method_not_allowed()
|
|
1517
|
+
|
|
1518
|
+
def do_PUT(self) -> None: # noqa: N802
|
|
1519
|
+
self._method_not_allowed()
|
|
1520
|
+
|
|
1521
|
+
def do_PATCH(self) -> None: # noqa: N802
|
|
1522
|
+
self._method_not_allowed()
|
|
1523
|
+
|
|
1524
|
+
def do_DELETE(self) -> None: # noqa: N802
|
|
1525
|
+
self._method_not_allowed()
|
|
1526
|
+
|
|
1527
|
+
def do_HEAD(self) -> None: # noqa: N802
|
|
1528
|
+
self._method_not_allowed()
|
|
1529
|
+
|
|
1530
|
+
def do_OPTIONS(self) -> None: # noqa: N802
|
|
1531
|
+
self._method_not_allowed()
|
|
1532
|
+
|
|
1533
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
1534
|
+
return
|
|
1535
|
+
|
|
1536
|
+
def _method_not_allowed(self) -> None:
|
|
1537
|
+
self._send_api_error(405, "MethodNotAllowed", "Only GET requests are allowed.")
|
|
1538
|
+
|
|
1539
|
+
def _send_text(self, status: int, text: str, *, content_type: str) -> None:
|
|
1540
|
+
body = text.encode("utf-8")
|
|
1541
|
+
try:
|
|
1542
|
+
self.send_response(status)
|
|
1543
|
+
self.send_header("Content-Type", content_type)
|
|
1544
|
+
self.send_header("Content-Length", str(len(body)))
|
|
1545
|
+
self.end_headers()
|
|
1546
|
+
self.wfile.write(body)
|
|
1547
|
+
except _CLIENT_DISCONNECT_ERRORS:
|
|
1548
|
+
return
|
|
1549
|
+
|
|
1550
|
+
def _send_json(self, status: int, payload: dict[str, object]) -> None:
|
|
1551
|
+
self._send_text(
|
|
1552
|
+
status,
|
|
1553
|
+
json.dumps(payload, sort_keys=True) + "\n",
|
|
1554
|
+
content_type="application/json",
|
|
1555
|
+
)
|
|
1556
|
+
|
|
1557
|
+
def _send_cached_json(
|
|
1558
|
+
self,
|
|
1559
|
+
status: int,
|
|
1560
|
+
revision: str,
|
|
1561
|
+
payload_factory: Callable[[], dict[str, object]],
|
|
1562
|
+
) -> None:
|
|
1563
|
+
if self.headers.get("If-None-Match") == revision:
|
|
1564
|
+
try:
|
|
1565
|
+
self.send_response(304)
|
|
1566
|
+
self.send_header("ETag", revision)
|
|
1567
|
+
self.send_header("Cache-Control", "no-cache")
|
|
1568
|
+
self.end_headers()
|
|
1569
|
+
except _CLIENT_DISCONNECT_ERRORS:
|
|
1570
|
+
return
|
|
1571
|
+
return
|
|
1572
|
+
|
|
1573
|
+
cache_key = f"{self.path}:{revision}"
|
|
1574
|
+
cached = self.server.cache.get(cache_key)
|
|
1575
|
+
if cached is None:
|
|
1576
|
+
payload = payload_factory()
|
|
1577
|
+
payload["revision"] = revision
|
|
1578
|
+
cached = CachedResponse(
|
|
1579
|
+
revision=revision,
|
|
1580
|
+
body=(json.dumps(payload, sort_keys=True).encode("utf-8") + b"\n"),
|
|
1581
|
+
content_type="application/json",
|
|
1582
|
+
)
|
|
1583
|
+
if len(self.server.cache) > 128:
|
|
1584
|
+
self.server.cache.clear()
|
|
1585
|
+
self.server.cache[cache_key] = cached
|
|
1586
|
+
|
|
1587
|
+
try:
|
|
1588
|
+
self.send_response(status)
|
|
1589
|
+
self.send_header("Content-Type", cached.content_type)
|
|
1590
|
+
self.send_header("Content-Length", str(len(cached.body)))
|
|
1591
|
+
self.send_header("ETag", revision)
|
|
1592
|
+
self.send_header("Cache-Control", "no-cache")
|
|
1593
|
+
self.end_headers()
|
|
1594
|
+
self.wfile.write(cached.body)
|
|
1595
|
+
except _CLIENT_DISCONNECT_ERRORS:
|
|
1596
|
+
return
|
|
1597
|
+
|
|
1598
|
+
def _send_api_error(self, status: int, error_type: str, message: str) -> None:
|
|
1599
|
+
self._send_json(
|
|
1600
|
+
status,
|
|
1601
|
+
{
|
|
1602
|
+
"ok": False,
|
|
1603
|
+
"error": {
|
|
1604
|
+
"type": error_type,
|
|
1605
|
+
"message": message,
|
|
1606
|
+
},
|
|
1607
|
+
},
|
|
1608
|
+
)
|
|
1609
|
+
|
|
1610
|
+
|
|
1611
|
+
def _task_ref_from_query(
|
|
1612
|
+
query: dict[str, list[str]],
|
|
1613
|
+
default_task_ref: str | None,
|
|
1614
|
+
) -> str | None:
|
|
1615
|
+
raw = _first_query_value(query, "task")
|
|
1616
|
+
if raw is None or raw == "active":
|
|
1617
|
+
return default_task_ref
|
|
1618
|
+
return raw
|
|
1619
|
+
|
|
1620
|
+
|
|
1621
|
+
def _limit_from_query(query: dict[str, list[str]]) -> int:
|
|
1622
|
+
raw = _first_query_value(query, "limit")
|
|
1623
|
+
if raw is None:
|
|
1624
|
+
return 50
|
|
1625
|
+
try:
|
|
1626
|
+
limit = int(raw)
|
|
1627
|
+
except ValueError as exc:
|
|
1628
|
+
raise ValueError("Invalid limit value.") from exc
|
|
1629
|
+
if limit <= 0:
|
|
1630
|
+
raise ValueError("limit must be greater than 0.")
|
|
1631
|
+
return limit
|
|
1632
|
+
|
|
1633
|
+
|
|
1634
|
+
def _first_query_value(query: dict[str, list[str]], name: str) -> str | None:
|
|
1635
|
+
values = query.get(name, [])
|
|
1636
|
+
if not values:
|
|
1637
|
+
return None
|
|
1638
|
+
return values[0].strip() or None
|
|
1639
|
+
|
|
1640
|
+
|
|
1641
|
+
def _storage_revision_for_project(workspace_root: Path) -> str:
|
|
1642
|
+
paths = resolve_v2_paths(workspace_root)
|
|
1643
|
+
return _revision_for_paths(
|
|
1644
|
+
paths.project_dir,
|
|
1645
|
+
[paths.active_task_path, *sorted(paths.tasks_dir.glob("task-*/task.md"))],
|
|
1646
|
+
)
|
|
1647
|
+
|
|
1648
|
+
|
|
1649
|
+
def _storage_revision_for_tasks(workspace_root: Path) -> str:
|
|
1650
|
+
paths = resolve_v2_paths(workspace_root)
|
|
1651
|
+
return _revision_for_paths(
|
|
1652
|
+
paths.project_dir,
|
|
1653
|
+
[
|
|
1654
|
+
*sorted(paths.tasks_dir.glob("task-*/task.md")),
|
|
1655
|
+
*sorted(paths.tasks_dir.glob("task-*/lock.yaml")),
|
|
1656
|
+
],
|
|
1657
|
+
)
|
|
1658
|
+
|
|
1659
|
+
|
|
1660
|
+
def _storage_revision_for_dashboard(
|
|
1661
|
+
workspace_root: Path,
|
|
1662
|
+
task_ref: str | None,
|
|
1663
|
+
) -> str:
|
|
1664
|
+
task = resolve_task_or_active(workspace_root, task_ref)
|
|
1665
|
+
paths = resolve_v2_paths(workspace_root)
|
|
1666
|
+
bundle = task_dir(paths, task.id)
|
|
1667
|
+
return _revision_for_paths(
|
|
1668
|
+
paths.project_dir,
|
|
1669
|
+
[bundle / "lock.yaml", *sorted(bundle.rglob("*.md"))],
|
|
1670
|
+
)
|
|
1671
|
+
|
|
1672
|
+
|
|
1673
|
+
def _storage_revision_for_events(
|
|
1674
|
+
workspace_root: Path,
|
|
1675
|
+
task_ref: str | None,
|
|
1676
|
+
) -> str:
|
|
1677
|
+
resolve_task_or_active(workspace_root, task_ref)
|
|
1678
|
+
paths = resolve_v2_paths(workspace_root)
|
|
1679
|
+
return _revision_for_paths(
|
|
1680
|
+
paths.project_dir,
|
|
1681
|
+
sorted(paths.events_dir.glob("*.ndjson")),
|
|
1682
|
+
)
|
|
1683
|
+
|
|
1684
|
+
|
|
1685
|
+
def _revision_for_paths(project_dir: Path, paths: list[Path]) -> str:
|
|
1686
|
+
parts: list[str] = []
|
|
1687
|
+
seen: set[Path] = set()
|
|
1688
|
+
for path in sorted(paths, key=lambda item: str(item)):
|
|
1689
|
+
if path in seen:
|
|
1690
|
+
continue
|
|
1691
|
+
seen.add(path)
|
|
1692
|
+
if path.exists():
|
|
1693
|
+
stat = path.stat()
|
|
1694
|
+
parts.append(
|
|
1695
|
+
f"{_relative_path(project_dir, path)}:{stat.st_mtime_ns}:{stat.st_size}"
|
|
1696
|
+
)
|
|
1697
|
+
else:
|
|
1698
|
+
parts.append(f"{_relative_path(project_dir, path)}:missing")
|
|
1699
|
+
return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
|
|
1700
|
+
|
|
1701
|
+
|
|
1702
|
+
def _relative_path(project_dir: Path, path: Path) -> str:
|
|
1703
|
+
try:
|
|
1704
|
+
return str(path.relative_to(project_dir))
|
|
1705
|
+
except ValueError:
|
|
1706
|
+
return str(path)
|
|
1707
|
+
|
|
1708
|
+
|
|
1709
|
+
def _safe_script_literal(value: str | None) -> str:
|
|
1710
|
+
encoded = json.dumps(value)
|
|
1711
|
+
return (
|
|
1712
|
+
encoded.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
|
|
1713
|
+
)
|
|
1714
|
+
|
|
1715
|
+
|
|
1716
|
+
def _server_url(host: str, port: int) -> str:
|
|
1717
|
+
if ":" in host and not host.startswith("["):
|
|
1718
|
+
host = f"[{host}]"
|
|
1719
|
+
return f"http://{host}:{port}/"
|
|
1720
|
+
|
|
1721
|
+
|
|
1722
|
+
def _status_for_launch_error(exc: LaunchError) -> tuple[int, str]:
|
|
1723
|
+
message = str(exc)
|
|
1724
|
+
if "Task not found:" in message or "Active task" in message:
|
|
1725
|
+
return 404, "NotFound"
|
|
1726
|
+
return 400, "BadRequest"
|
|
1727
|
+
|
|
1728
|
+
|
|
1729
|
+
_CLIENT_DISCONNECT_ERRORS = (
|
|
1730
|
+
BrokenPipeError,
|
|
1731
|
+
ConnectionAbortedError,
|
|
1732
|
+
ConnectionResetError,
|
|
1733
|
+
)
|
|
1734
|
+
|
|
1735
|
+
|
|
1736
|
+
__all__ = [
|
|
1737
|
+
"DashboardServerConfig",
|
|
1738
|
+
"DashboardServerHandle",
|
|
1739
|
+
"launch_dashboard_server",
|
|
1740
|
+
"render_index_html",
|
|
1741
|
+
"serve_dashboard",
|
|
1742
|
+
]
|