Skip to content

Commit 3dd3299

Browse files
Merge pull request #157 from cubewise-code/feat/issue-156-chore-task-kind
feat: TM1 chore as a first-class task kind (closes #156)
2 parents ef917e8 + c62f822 commit 3dd3299

22 files changed

Lines changed: 1644 additions & 124 deletions

CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,34 @@
22

33
All notable changes to RushTI are documented in this file.
44

5+
## Unreleased — `feat/issue-156-chore-task-kind`
6+
7+
- **Added: TM1 chore execution as a first-class task kind** (closes #156).
8+
Mixed process + chore taskfiles are now supported across JSON, TXT, and
9+
cube sources. A task carries exactly one of `process` or `chore` — the
10+
field name is the discriminator. Chores are intentionally narrower than
11+
processes: no parameters, no minor-error tier, no native timeout; only
12+
`safe_retry` is honoured (and restricted to `SINGLE_COMMIT` chores so
13+
partial state cannot leak on failure). See `docs/adr/0002-polymorphic-task-kinds.md`
14+
for the full rationale.
15+
- Added: `chore TEXT` column on the `task_results` SQLite table.
16+
Auto-migrated on first connection — no manual step required.
17+
- **Behavioural change:** `}rushti.load.results` TI schema changed. A new
18+
`chore` measure element is added to the cube and a matching `vchore`
19+
variable to the TI variable list. CSVs written by the previous version
20+
cannot be loaded by the new TI. Mitigation: flush any pending CSVs
21+
before upgrade (default `pDeleteSourceFile=1` makes CSVs transient in
22+
practice), then re-run `rushti build --tm1-instance X` so the additive
23+
merge adds the new measure element to the existing cube.
24+
- **Behavioural change:** TXT taskfiles now run through `validate_taskfile`
25+
on read. Pre-existing malformed TXT files (missing fields, wrong types,
26+
chore tasks carrying process-only fields) that previously parsed
27+
silently will now fail with explicit error messages. This closes a
28+
long-standing gap; the fix surfaces bugs that were always present.
29+
- Dashboard: the per-task "Process" column is replaced by a unified
30+
"Task target" column with a `[P]` / `[C]` kind indicator so process
31+
and chore rows render side-by-side.
32+
533
## Unreleased — docs: `--mode` for cube reads (#160)
634

735
- **Docs fix:** corrected the long-standing claim that `--mode` is

CONTEXT.md

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,39 @@ Use **"workflow"** as an adjective for scope ("workflow-level setting",
3535
"per-workflow override") rather than as a synonym for "taskfile".
3636

3737
### Task
38-
A single TI process execution inside a taskfile. Each task has:
38+
A single execution unit inside a taskfile. Each task has:
3939
- `id` — a positive integer string, used as an element name in
4040
`rushti_task_id`.
4141
- `instance`**the TM1 instance where this task executes** (task-level).
42-
- `process` — the TI process name on that instance.
43-
- `parameters`, `predecessors`, `stage`, `timeout`, etc.
42+
- exactly one **kind field** — either `process` (TI process) or `chore`
43+
(TM1 chore). The field name *is* the discriminator; there is no
44+
meta-`kind` field. See [[adr/0002-polymorphic-task-kinds]].
45+
- kind-specific and shared optional fields — see **Task kind** below.
46+
47+
### Task kind
48+
RushTI supports two task kinds, identified by which field names the
49+
execution target:
50+
51+
| Kind | Field | Applicable optional fields |
52+
|---|---|---|
53+
| **process** (TI process) | `process` | `parameters`, `succeed_on_minor_errors`, `timeout`, `cancel_at_timeout`, `safe_retry`, plus shared (below) |
54+
| **chore** (TM1 chore) | `chore` | `safe_retry` (only when chore is `SINGLE_COMMIT`), plus shared |
55+
56+
**Shared optional fields** (apply to both kinds): `predecessors`, `stage`,
57+
`require_predecessor_success`.
58+
59+
A task with both `process` and `chore` set is invalid; a task with
60+
neither is invalid. The invariant is enforced at parse-time validation
61+
*and* as a class invariant in `Task.__init__`.
62+
63+
Chores are intentionally narrower than processes:
64+
- **No parameters** — TM1 chores have no invocation parameters.
65+
- **No timeout / cancel** — TM1 chore execution has no native timeout.
66+
- **No minor-error tier** — chore execution is binary at the API
67+
boundary (HTTP 204 = success, 500 = failure).
68+
- **Retry is whole-chore.** When `safe_retry: true` on a chore task,
69+
retry re-executes the entire chore. Restricted to SINGLE_COMMIT
70+
chores so partial state never leaks on failure.
4471

4572
### TM1 instance
4673
A named TM1 server defined in `config.ini`. Three roles can apply

src/rushti/dashboard.py

Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -234,16 +234,26 @@ def _prepare_dashboard_data(
234234
}
235235
)
236236

237-
# Build per-task aggregate data (across all runs)
237+
# Build per-task aggregate data (across all runs). Each row carries
238+
# both ``process`` (may be empty for chore rows) and ``chore`` so the
239+
# dashboard can render a unified "Task target" column with a kind
240+
# indicator. ``task_target``/``task_kind`` are the canonical fields
241+
# for display; the raw kind fields stay populated for backward
242+
# compatibility with downstream consumers.
238243
task_data: Dict[str, Dict[str, Any]] = {}
239244
for tr in task_results:
240245
sig = tr["task_signature"]
246+
process_val = tr.get("process") or ""
247+
chore_val = tr.get("chore") or ""
241248
if sig not in task_data:
242249
task_data[sig] = {
243250
"task_signature": sig,
244251
"task_id": tr["task_id"],
245252
"instance": tr["instance"],
246-
"process": tr["process"],
253+
"process": process_val,
254+
"chore": chore_val,
255+
"task_target": chore_val or process_val,
256+
"task_kind": "chore" if chore_val else "process",
247257
"durations": [],
248258
"successes": 0,
249259
"total": 0,
@@ -263,6 +273,9 @@ def _prepare_dashboard_data(
263273
"task_id": data["task_id"],
264274
"instance": data["instance"],
265275
"process": data["process"],
276+
"chore": data["chore"],
277+
"task_target": data["task_target"],
278+
"task_kind": data["task_kind"],
266279
"executions": data["total"],
267280
"success_rate": (
268281
round(data["successes"] / data["total"] * 100, 1) if data["total"] > 0 else 0
@@ -290,10 +303,15 @@ def _prepare_dashboard_data(
290303
outliers = []
291304
for tr in task_results:
292305
if tr["duration_seconds"] is not None:
306+
process_val = tr.get("process") or ""
307+
chore_val = tr.get("chore") or ""
293308
outliers.append(
294309
{
295310
"task_id": tr["task_id"],
296-
"process": tr["process"],
311+
"process": process_val,
312+
"chore": chore_val,
313+
"task_target": chore_val or process_val,
314+
"task_kind": "chore" if chore_val else "process",
297315
"instance": tr["instance"],
298316
"run_id": tr["run_id"],
299317
"duration": round(tr["duration_seconds"], 2),
@@ -308,10 +326,15 @@ def _prepare_dashboard_data(
308326
failures = []
309327
for tr in task_results:
310328
if tr["status"] != "Success":
329+
process_val = tr.get("process") or ""
330+
chore_val = tr.get("chore") or ""
311331
failures.append(
312332
{
313333
"task_id": tr["task_id"],
314-
"process": tr["process"],
334+
"process": process_val,
335+
"chore": chore_val,
336+
"task_target": chore_val or process_val,
337+
"task_kind": "chore" if chore_val else "process",
315338
"instance": tr["instance"],
316339
"run_id": tr["run_id"],
317340
"duration": round(tr["duration_seconds"], 2) if tr["duration_seconds"] else 0,
@@ -322,13 +345,18 @@ def _prepare_dashboard_data(
322345
# Slim task_results for JS (only fields needed for interactive filtering)
323346
slim_task_results = []
324347
for tr in task_results:
348+
process_val = tr.get("process") or ""
349+
chore_val = tr.get("chore") or ""
325350
slim_task_results.append(
326351
{
327352
"run_id": tr["run_id"],
328353
"task_id": tr["task_id"],
329354
"task_signature": tr["task_signature"],
330355
"instance": tr["instance"],
331-
"process": tr["process"],
356+
"process": process_val,
357+
"chore": chore_val,
358+
"task_target": chore_val or process_val,
359+
"task_kind": "chore" if chore_val else "process",
332360
"status": tr["status"],
333361
"duration_seconds": tr["duration_seconds"],
334362
"error_message": tr.get("error_message"),
@@ -578,6 +606,11 @@ def generate_dashboard(
578606
.status-partial {{ color: #D97706; font-weight: 600; }}
579607
.high-cv {{ background: #FFF7ED; }}
580608
.duration-slow {{ color: #DC2626; font-weight: 600; }}
609+
.kind-tag {{
610+
display: inline-block; padding: 1px 5px; border-radius: 4px;
611+
font-size: 0.7rem; font-weight: 700; color: #475569;
612+
background: #E2E8F0; margin-right: 4px;
613+
}}
581614
582615
/* Accordion */
583616
.accordion {{ margin-bottom: 20px; }}
@@ -752,7 +785,7 @@ def generate_dashboard(
752785
<thead><tr>
753786
<th onclick="sortTaskTable(0)">Task ID <span class="sort-arrow"></span></th>
754787
<th onclick="sortTaskTable(1)">Instance <span class="sort-arrow"></span></th>
755-
<th onclick="sortTaskTable(2)">Process <span class="sort-arrow"></span></th>
788+
<th onclick="sortTaskTable(2)">Task target <span class="sort-arrow"></span></th>
756789
<th onclick="sortTaskTable(3)">Avg (s) <span class="sort-arrow"></span></th>
757790
<th onclick="sortTaskTable(4)">Min (s) <span class="sort-arrow"></span></th>
758791
<th onclick="sortTaskTable(5)">Max (s) <span class="sort-arrow"></span></th>
@@ -768,7 +801,7 @@ def generate_dashboard(
768801
<h3>Top 10 Slowest Executions <span class="help-icon">?<span class="help-tip">How to read: These are the 10 longest-running individual task executions across the selected runs. The "vs Median" column tells you how many times slower each one was compared to the typical task — a high multiplier (e.g., 5×) means that execution was significantly slower than usual and may be worth investigating. Check whether the same task appears multiple times — if so, it is a consistent bottleneck.</span></span></h3>
769802
<table id="outlierTable">
770803
<thead><tr>
771-
<th>Task ID</th><th>Process</th><th>Instance</th>
804+
<th>Task ID</th><th>Task target</th><th>Instance</th>
772805
<th>Run</th><th>Duration (s)</th><th>vs Median</th><th>Status</th>
773806
</tr></thead>
774807
<tbody id="outlierTableBody"></tbody>
@@ -779,7 +812,7 @@ def generate_dashboard(
779812
<h3>Failed Processes <span class="help-icon">?<span class="help-tip">How to read: Every task execution that did not succeed is listed here. Look for patterns — does the same task fail repeatedly across runs? That points to a systemic issue. If failures are scattered across different tasks, the problem may be environmental (server load, connectivity). Hover over the Error column to see the full error message for each failure.</span></span></h3>
780813
<table id="failureTable">
781814
<thead><tr>
782-
<th>Task ID</th><th>Process</th><th>Instance</th>
815+
<th>Task ID</th><th>Task target</th><th>Instance</th>
783816
<th>Run</th><th>Duration (s)</th><th>Error</th>
784817
</tr></thead>
785818
<tbody id="failureTableBody"></tbody>
@@ -1260,14 +1293,14 @@ def generate_dashboard(
12601293
// Also store a lookup for tooltip labels
12611294
const sigLabel = {{}};
12621295
relevant.forEach(tr => {{
1263-
if (!sigLabel[tr.task_signature]) sigLabel[tr.task_signature] = tr.process;
1296+
if (!sigLabel[tr.task_signature]) sigLabel[tr.task_signature] = tr.task_target || tr.process || tr.chore;
12641297
}});
12651298
12661299
const datasets = runs.map((run, idx) => {{
12671300
const runTasks = relevant.filter(tr => tr.run_id === run.run_id);
12681301
return {{
12691302
label: formatDate(run.start_time),
1270-
data: runTasks.map(t => ({{ x: sigIndex[t.task_signature], y: t.duration_seconds || 0, _sig: t.task_signature, _task: t.task_id, _proc: t.process }})),
1303+
data: runTasks.map(t => ({{ x: sigIndex[t.task_signature], y: t.duration_seconds || 0, _sig: t.task_signature, _task: t.task_id, _proc: t.task_target || t.process || t.chore }})),
12711304
backgroundColor: RUN_COLORS[idx % RUN_COLORS.length] + '80',
12721305
borderColor: RUN_COLORS[idx % RUN_COLORS.length],
12731306
pointRadius: 3,
@@ -1470,7 +1503,10 @@ def generate_dashboard(
14701503
const sig = tr.task_signature;
14711504
if (!taskData[sig]) {{
14721505
taskData[sig] = {{
1473-
task_id: tr.task_id, instance: tr.instance, process: tr.process,
1506+
task_id: tr.task_id, instance: tr.instance,
1507+
process: tr.process, chore: tr.chore,
1508+
task_target: tr.task_target || tr.process || tr.chore,
1509+
task_kind: tr.task_kind || (tr.chore ? 'chore' : 'process'),
14741510
durations: [], successes: 0, total: 0
14751511
}};
14761512
}}
@@ -1492,7 +1528,7 @@ def generate_dashboard(
14921528
}}
14931529
14941530
function applyTaskSort(rows) {{
1495-
const fields = ['task_id', 'instance', 'process', 'avg', 'mn', 'mx', 'stdDev', 'successRate', 'total'];
1531+
const fields = ['task_id', 'instance', 'task_target', 'avg', 'mn', 'mx', 'stdDev', 'successRate', 'total'];
14961532
const field = fields[taskTableSortCol];
14971533
rows.sort((a, b) => {{
14981534
const av = a[field], bv = b[field];
@@ -1556,10 +1592,12 @@ def generate_dashboard(
15561592
tbody.innerHTML = pageRows.map(r => {{
15571593
const cvClass = r.cv > 0.5 ? 'high-cv' : '';
15581594
const statusCls = r.successRate < 100 ? 'status-fail' : 'status-success';
1595+
const kindTag = r.task_kind === 'chore' ? '[C]' : '[P]';
1596+
const target = r.task_target || r.process || r.chore || '';
15591597
return `<tr class="${{cvClass}}">
15601598
<td class="mono">${{r.task_id}}</td>
15611599
<td>${{r.instance}}</td>
1562-
<td>${{r.process}}</td>
1600+
<td><span class="kind-tag">${{kindTag}}</span> ${{target}}</td>
15631601
<td class="mono">${{r.avg.toFixed(2)}}</td>
15641602
<td class="mono">${{r.mn.toFixed(2)}}</td>
15651603
<td class="mono">${{r.mx.toFixed(2)}}</td>
@@ -1585,9 +1623,11 @@ def generate_dashboard(
15851623
tbody.innerHTML = sorted.map(o => {{
15861624
const statusCls = o.status === 'Success' ? 'status-success' : 'status-fail';
15871625
const vsMedian = o.duration_seconds - median;
1626+
const kindTag = o.task_kind === 'chore' || o.chore ? '[C]' : '[P]';
1627+
const target = o.task_target || o.process || o.chore || '';
15881628
return `<tr>
15891629
<td class="mono">${{o.task_id}}</td>
1590-
<td>${{o.process}}</td>
1630+
<td><span class="kind-tag">${{kindTag}}</span> ${{target}}</td>
15911631
<td>${{o.instance}}</td>
15921632
<td class="mono">${{o.run_id}}</td>
15931633
<td class="mono duration-slow">${{o.duration_seconds.toFixed(2)}}</td>
@@ -1613,15 +1653,19 @@ def generate_dashboard(
16131653
}}
16141654
16151655
panel.style.display = 'block';
1616-
tbody.innerHTML = filtered.map(f => `<tr>
1656+
tbody.innerHTML = filtered.map(f => {{
1657+
const kindTag = f.task_kind === 'chore' || f.chore ? '[C]' : '[P]';
1658+
const target = f.task_target || f.process || f.chore || '';
1659+
return `<tr>
16171660
<td class="mono">${{f.task_id}}</td>
1618-
<td>${{f.process}}</td>
1661+
<td><span class="kind-tag">${{kindTag}}</span> ${{target}}</td>
16191662
<td>${{f.instance}}</td>
16201663
<td class="mono">${{f.run_id}}</td>
16211664
<td class="mono">${{f.duration_seconds != null ? f.duration_seconds.toFixed(2) : '-'}}</td>
16221665
<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
16231666
title="${{(f.error_message || '').replace(/"/g, '&quot;')}}">${{f.error_message || '-'}}</td>
1624-
</tr>`).join('');
1667+
</tr>`;
1668+
}}).join('');
16251669
}}
16261670
16271671
function updateConfigDetails(runs) {{

0 commit comments

Comments
 (0)